Repository navigation
feat(q&a): localized questions, new schema and seeding #71
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,72 +1,78 @@ | ||
| import { NextResponse } from 'next/server'; | ||
| import { db } from '@/db'; | ||
| import { questions, categories } from '@/db/schema'; | ||
| import { questions } from '@/db/schema'; | ||
| import { eq, sql, and, ilike } from 'drizzle-orm'; | ||
|
|
||
| const DEFAULT_PAGE = 1; | ||
| const DEFAULT_LIMIT = 10; | ||
| const DEFAULT_LIMIT = 20; | ||
| const DEFAULT_LOCALE = 'uk'; | ||
|
|
||
| export async function GET( | ||
| req: Request, | ||
| ctx: { params: Promise<{ category: string }> } | ||
| ) { | ||
| const { category } = await ctx.params; | ||
| const { searchParams } = new URL(req.url); | ||
| try { | ||
| const { category } = await ctx.params; | ||
| const { searchParams } = new URL(req.url); | ||
|
|
||
| const page = Math.max( | ||
| 1, | ||
| parseInt(searchParams.get('page') || String(DEFAULT_PAGE), 10) | ||
| ); | ||
| const limit = Math.max( | ||
| 1, | ||
| Math.min( | ||
| const page = Math.max(1, Number(searchParams.get('page') ?? DEFAULT_PAGE)); | ||
| const limit = Math.min( | ||
| 50, | ||
| parseInt(searchParams.get('limit') || String(DEFAULT_LIMIT), 10) | ||
| ) | ||
| ); | ||
| const search = searchParams.get('search')?.trim() || ''; | ||
| const offset = (page - 1) * limit; | ||
| Math.max(1, Number(searchParams.get('limit') ?? DEFAULT_LIMIT)) | ||
| ); | ||
| const offset = (page - 1) * limit; | ||
|
|
||
| const cat = await db | ||
| .select() | ||
| .from(categories) | ||
| .where(eq(categories.name, category)) | ||
| .limit(1); | ||
| const locale = | ||
| searchParams.get('locale') || | ||
| req.headers.get('x-locale') || | ||
| DEFAULT_LOCALE; | ||
|
|
||
| if (!cat.length) { | ||
| return NextResponse.json({ | ||
| items: [], | ||
| total: 0, | ||
| page, | ||
| totalPages: 0, | ||
| }); | ||
| } | ||
| const search = searchParams.get('search')?.trim(); | ||
|
|
||
| const baseCondition = and( | ||
| eq(questions.categorySlug, category.toLowerCase()), | ||
| eq(questions.locale, locale) | ||
| ); | ||
|
Comment on lines
+32
to
+35
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Validate and sanitize the category slug to prevent injection and errors. The category parameter from the URL is lowercased but not validated. This could allow malicious or malformed input to reach the database query. 🔎 Proposed category validation+// Add at the top of the function
+const categorySlugPattern = /^[a-z0-9-]+$/;
+
const { category } = await ctx.params;
+
+if (!categorySlugPattern.test(category.toLowerCase())) {
+ return NextResponse.json(
+ { error: 'Invalid category format' },
+ { status: 400 }
+ );
+}
+
const { searchParams } = new URL(req.url);
🤖 Prompt for AI Agents |
||
|
|
||
| const baseCondition = eq(questions.categoryId, cat[0].id); | ||
| const whereCondition = search | ||
| ? and(baseCondition, ilike(questions.question, `%${search}%`)) | ||
| : baseCondition; | ||
| const whereCondition = search | ||
| ? and(baseCondition, ilike(questions.question, `%${search}%`)) | ||
| : baseCondition; | ||
|
Comment on lines
+30
to
+39
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Escape SQL wildcard characters in the search parameter. Drizzle ORM's -const search = searchParams.get('search')?.trim();
+const search = searchParams.get('search')?.trim().replace(/[%_]/g, '\\$&');This ensures that literal
🤖 Prompt for AI Agents |
||
|
|
||
| const [countResult] = await db | ||
| .select({ count: sql<number>`count(*)` }) | ||
| .from(questions) | ||
| .where(whereCondition); | ||
| const [{ count }] = await db | ||
| .select({ count: sql<number>`count(*)` }) | ||
| .from(questions) | ||
| .where(whereCondition); | ||
|
|
||
| const total = Number(countResult.count); | ||
| const totalPages = Math.ceil(total / limit); | ||
| const total = Number(count); | ||
| const totalPages = Math.ceil(total / limit); | ||
|
|
||
| const items = await db | ||
| .select() | ||
| .from(questions) | ||
| .where(whereCondition) | ||
| .orderBy(questions.id) | ||
| .limit(limit) | ||
| .offset(offset); | ||
| const items = await db | ||
| .select() | ||
| .from(questions) | ||
| .where(whereCondition) | ||
| .orderBy(questions.sortOrder) | ||
| .limit(limit) | ||
| .offset(offset); | ||
|
|
||
| return NextResponse.json({ | ||
| items, | ||
| total, | ||
| page, | ||
| totalPages, | ||
| }); | ||
| return NextResponse.json({ | ||
| items, | ||
| total, | ||
| page, | ||
| totalPages, | ||
| locale, | ||
| }); | ||
| } catch (error) { | ||
| console.error('[GET /api/questions/:category]', error); | ||
|
|
||
| return NextResponse.json( | ||
| { | ||
| items: [], | ||
| total: 0, | ||
| page: 1, | ||
| totalPages: 0, | ||
| locale: DEFAULT_LOCALE, | ||
| }, | ||
| { status: 500 } | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,10 +1,11 @@ | ||||||||||||
| 'use client'; | ||||||||||||
|
|
||||||||||||
| import { useState, useEffect, useCallback, useRef } from 'react'; | ||||||||||||
| import { useSearchParams } from 'next/navigation'; | ||||||||||||
| import { useSearchParams, useParams } from 'next/navigation'; | ||||||||||||
| import { useRouter } from '@/i18n/routing'; | ||||||||||||
| import { useTranslations } from 'next-intl'; | ||||||||||||
| import { Search, X } from 'lucide-react'; | ||||||||||||
|
|
||||||||||||
| import AccordionList from '@/components/q&a/AccordionList'; | ||||||||||||
| import { Pagination } from '@/components/q&a/Pagination'; | ||||||||||||
| import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; | ||||||||||||
|
|
@@ -24,8 +25,11 @@ export default function TabsSection() { | |||||||||||
| const t = useTranslations('qa'); | ||||||||||||
| const router = useRouter(); | ||||||||||||
| const searchParams = useSearchParams(); | ||||||||||||
| const params = useParams(); | ||||||||||||
|
|
||||||||||||
| const locale = params.locale as string; | ||||||||||||
|
Comment on lines
+28
to
+30
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unsafe type assertion for
🔎 Proposed fix with validation const params = useParams();
-
- const locale = params.locale as string;
+ const locale = typeof params.locale === 'string' ? params.locale : 'en';📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||
|
|
||||||||||||
| const pageFromUrl = parseInt(searchParams.get('page') || '1', 10); | ||||||||||||
| const pageFromUrl = Number(searchParams.get('page') || 1); | ||||||||||||
| const categoryFromUrl = searchParams.get('category') || DEFAULT_CATEGORY; | ||||||||||||
| const searchFromUrl = searchParams.get('search') || ''; | ||||||||||||
|
|
||||||||||||
|
|
@@ -40,56 +44,59 @@ export default function TabsSection() { | |||||||||||
| const [isLoading, setIsLoading] = useState(true); | ||||||||||||
|
|
||||||||||||
| const debounceRef = useRef<NodeJS.Timeout | null>(null); | ||||||||||||
| const mountedRef = useRef(false); | ||||||||||||
|
|
||||||||||||
| const updateUrl = useCallback( | ||||||||||||
| (category: string, page: number, search: string) => { | ||||||||||||
| const params = new URLSearchParams(); | ||||||||||||
| if (category !== DEFAULT_CATEGORY) { | ||||||||||||
| params.set('category', category); | ||||||||||||
| } | ||||||||||||
| if (page > 1) { | ||||||||||||
| params.set('page', String(page)); | ||||||||||||
| } | ||||||||||||
| if (search) { | ||||||||||||
| params.set('search', search); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| if (category !== DEFAULT_CATEGORY) params.set('category', category); | ||||||||||||
| if (page > 1) params.set('page', String(page)); | ||||||||||||
| if (search) params.set('search', search); | ||||||||||||
|
|
||||||||||||
| const queryString = params.toString(); | ||||||||||||
| router.push(`/q&a${queryString ? `?${queryString}` : ''}`, { | ||||||||||||
|
|
||||||||||||
| router.replace(`/q&a${queryString ? `?${queryString}` : ''}`, { | ||||||||||||
| scroll: false, | ||||||||||||
| }); | ||||||||||||
| }, | ||||||||||||
| [router] | ||||||||||||
| ); | ||||||||||||
|
|
||||||||||||
| useEffect(() => { | ||||||||||||
| if (debounceRef.current) { | ||||||||||||
| clearTimeout(debounceRef.current); | ||||||||||||
| if (!mountedRef.current) { | ||||||||||||
| mountedRef.current = true; | ||||||||||||
| return; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| if (debounceRef.current) clearTimeout(debounceRef.current); | ||||||||||||
|
|
||||||||||||
| debounceRef.current = setTimeout(() => { | ||||||||||||
| setDebouncedSearch(searchQuery); | ||||||||||||
| setCurrentPage(1); | ||||||||||||
| updateUrl(active, 1, searchQuery); | ||||||||||||
| }, DEBOUNCE_MS); | ||||||||||||
|
|
||||||||||||
| return () => { | ||||||||||||
| if (debounceRef.current) { | ||||||||||||
| clearTimeout(debounceRef.current); | ||||||||||||
| } | ||||||||||||
| if (debounceRef.current) clearTimeout(debounceRef.current); | ||||||||||||
| }; | ||||||||||||
| }, [searchQuery]); | ||||||||||||
| }, [searchQuery, active, updateUrl]); | ||||||||||||
|
|
||||||||||||
| useEffect(() => { | ||||||||||||
| async function load() { | ||||||||||||
| setIsLoading(true); | ||||||||||||
|
|
||||||||||||
| try { | ||||||||||||
| const searchParam = debouncedSearch | ||||||||||||
| ? `&search=${encodeURIComponent(debouncedSearch)}` | ||||||||||||
| : ''; | ||||||||||||
|
|
||||||||||||
| const res = await fetch( | ||||||||||||
| `/api/questions/${active}?page=${currentPage}&limit=10${searchParam}` | ||||||||||||
| `/api/questions/${active}?page=${currentPage}&limit=10&locale=${locale}${searchParam}` | ||||||||||||
| ); | ||||||||||||
|
|
||||||||||||
| const data: PaginatedResponse = await res.json(); | ||||||||||||
|
|
||||||||||||
| setItems(data.items); | ||||||||||||
| setTotalPages(data.totalPages); | ||||||||||||
| } catch (error) { | ||||||||||||
|
|
@@ -100,8 +107,9 @@ export default function TabsSection() { | |||||||||||
| setIsLoading(false); | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| load(); | ||||||||||||
| }, [active, currentPage, debouncedSearch]); | ||||||||||||
| }, [active, currentPage, debouncedSearch, locale]); | ||||||||||||
|
|
||||||||||||
| const handleCategoryChange = (category: string) => { | ||||||||||||
| setActive(category); | ||||||||||||
|
|
@@ -117,46 +125,35 @@ export default function TabsSection() { | |||||||||||
| window.scrollTo({ top: 0, behavior: 'smooth' }); | ||||||||||||
| }; | ||||||||||||
|
|
||||||||||||
| const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||||||||||
| setSearchQuery(e.target.value); | ||||||||||||
| }; | ||||||||||||
|
|
||||||||||||
| const handleClearSearch = () => { | ||||||||||||
| setSearchQuery(''); | ||||||||||||
| setDebouncedSearch(''); | ||||||||||||
| setCurrentPage(1); | ||||||||||||
| updateUrl(active, 1, ''); | ||||||||||||
| }; | ||||||||||||
|
|
||||||||||||
| return ( | ||||||||||||
| <div className="w-full"> | ||||||||||||
| <div className="relative mb-6"> | ||||||||||||
| <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||||||||||||
| <Search className="h-5 w-5 text-gray-400" /> | ||||||||||||
| </div> | ||||||||||||
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" /> | ||||||||||||
|
|
||||||||||||
| <input | ||||||||||||
| type="text" | ||||||||||||
| value={searchQuery} | ||||||||||||
| onChange={handleSearchChange} | ||||||||||||
| onChange={e => setSearchQuery(e.target.value)} | ||||||||||||
| placeholder={t('searchPlaceholder')} | ||||||||||||
| className="block w-full pl-10 pr-10 py-3 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors" | ||||||||||||
| className="w-full pl-10 pr-10 py-3 border rounded-lg" | ||||||||||||
| /> | ||||||||||||
|
|
||||||||||||
| {searchQuery && ( | ||||||||||||
| <button | ||||||||||||
| onClick={handleClearSearch} | ||||||||||||
| className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" | ||||||||||||
| aria-label={t('clearSearch')} | ||||||||||||
| onClick={() => { | ||||||||||||
| setSearchQuery(''); | ||||||||||||
| setDebouncedSearch(''); | ||||||||||||
| setCurrentPage(1); | ||||||||||||
| updateUrl(active, 1, ''); | ||||||||||||
| }} | ||||||||||||
| className="absolute right-3 top-1/2 -translate-y-1/2" | ||||||||||||
| > | ||||||||||||
| <X className="h-5 w-5" /> | ||||||||||||
| </button> | ||||||||||||
| )} | ||||||||||||
| </div> | ||||||||||||
|
|
||||||||||||
| <Tabs | ||||||||||||
| value={active} | ||||||||||||
| onValueChange={handleCategoryChange} | ||||||||||||
| className="w-full" | ||||||||||||
| > | ||||||||||||
| <Tabs value={active} onValueChange={handleCategoryChange}> | ||||||||||||
| <TabsList className="grid grid-cols-7 mb-6"> | ||||||||||||
| {categoryNames.map(c => ( | ||||||||||||
| <TabsTrigger key={c} value={c}> | ||||||||||||
|
|
@@ -169,12 +166,12 @@ export default function TabsSection() { | |||||||||||
| <TabsContent key={c} value={c}> | ||||||||||||
| {isLoading ? ( | ||||||||||||
| <div className="flex justify-center py-12"> | ||||||||||||
| <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" /> | ||||||||||||
| <div className="animate-spin h-8 w-8 border-b-2" /> | ||||||||||||
| </div> | ||||||||||||
| ) : items.length > 0 ? ( | ||||||||||||
| ) : items.length ? ( | ||||||||||||
| <AccordionList items={items} /> | ||||||||||||
| ) : ( | ||||||||||||
| <p className="text-center py-12 text-gray-500 dark:text-gray-400"> | ||||||||||||
| <p className="text-center py-12"> | ||||||||||||
| {debouncedSearch | ||||||||||||
| ? t('noResults', { query: debouncedSearch }) | ||||||||||||
| : t('noQuestions')} | ||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,27 +1,14 @@ | ||
| import { pgTable, uuid, text, jsonb } from 'drizzle-orm/pg-core'; | ||
| import { relations } from 'drizzle-orm'; | ||
|
|
||
| export const categories = pgTable('categories', { | ||
| id: uuid('id').defaultRandom().primaryKey(), | ||
| name: text('name').notNull().unique(), | ||
| }); | ||
|
|
||
| export const questions = pgTable('questions', { | ||
| id: uuid('id').defaultRandom().primaryKey(), | ||
| question: text('question').notNull(), | ||
| answerBlocks: jsonb('answer_blocks').notNull(), | ||
| categoryId: uuid('category_id') | ||
| .notNull() | ||
| .references(() => categories.id, { onDelete: 'cascade' }), | ||
| }); | ||
|
|
||
| export const categoriesRelations = relations(categories, ({ many }) => ({ | ||
| questions: many(questions), | ||
| })); | ||
|
|
||
| export const questionsRelations = relations(questions, ({ one }) => ({ | ||
| category: one(categories, { | ||
| fields: [questions.categoryId], | ||
| references: [categories.id], | ||
| }), | ||
| })); | ||
| import { pgTable, uuid, varchar, text, unique } from 'drizzle-orm/pg-core'; | ||
|
|
||
| export const categories = pgTable( | ||
| 'categories', | ||
| { | ||
| id: uuid('id').defaultRandom().primaryKey(), | ||
| slug: varchar('slug', { length: 50 }).notNull(), | ||
| locale: varchar('locale', { length: 5 }).notNull(), | ||
| title: text('title').notNull(), | ||
| }, | ||
| table => [ | ||
| unique('categories_slug_locale_unique').on(table.slug, table.locale), | ||
| ] | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| export * from './categories'; | ||
| export * from './questions'; | ||
| export * from './quiz'; | ||
| export * from './users'; | ||
| export * from './points'; | ||
| export * from "./shop"; | ||
| export * from './shop'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Add validation for locale parameter to prevent invalid values.
The locale is accepted from query parameters or headers without validation. Invalid locale values could lead to unexpected behavior or empty result sets.
🔎 Proposed locale validation
📝 Committable suggestion
🤖 Prompt for AI Agents