Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 58 additions & 52 deletions frontend/app/api/questions/[category]/route.ts
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;
Comment on lines +25 to +28

Copy link
Copy Markdown
Contributor

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
+const VALID_LOCALES = ['en', 'pl', 'uk'] as const;
+
 const locale =
   searchParams.get('locale') ||
   req.headers.get('x-locale') ||
   DEFAULT_LOCALE;
+
+if (!VALID_LOCALES.includes(locale as any)) {
+  return NextResponse.json(
+    { error: `Invalid locale: ${locale}` },
+    { status: 400 }
+  );
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const locale =
searchParams.get('locale') ||
req.headers.get('x-locale') ||
DEFAULT_LOCALE;
const VALID_LOCALES = ['en', 'pl', 'uk'] as const;
const locale =
searchParams.get('locale') ||
req.headers.get('x-locale') ||
DEFAULT_LOCALE;
if (!VALID_LOCALES.includes(locale as any)) {
return NextResponse.json(
{ error: `Invalid locale: ${locale}` },
{ status: 400 }
);
}
🤖 Prompt for AI Agents
In frontend/app/api/questions/[category]/route.ts around lines 25 to 28, the
locale pulled from searchParams or x-locale header is used without validation;
validate the resolved locale against your allowed list (e.g. SUPPORTED_LOCALES
or a constant array including DEFAULT_LOCALE) and if the value is not in that
list, replace it with DEFAULT_LOCALE (or return a 400 Bad Request if you prefer
strict validation); implement the check immediately after computing locale and
ensure downstream code only sees a validated locale value.


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
frontend/app/api/questions/[category]/route.ts around lines 32 to 35: the
category slug from the URL is only lowercased and not validated, which can allow
malformed or malicious input into the DB query; validate and sanitize it before
use by decoding and trimming the input, enforce a strict pattern/length (e.g.,
allow only lowercase letters, numbers, hyphens, max length ~100) or similar
project-specific slug regex, reject invalid slugs with a 400 response (or
normalize acceptable characters), and only then use the sanitizedLowercasedSlug
in the query.


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Escape SQL wildcard characters in the search parameter.

Drizzle ORM's ilike() function uses parameterized queries, which properly protects against SQL injection. However, user input containing % or _ characters will be interpreted as LIKE wildcards rather than literals, causing unexpected search behavior. Sanitize the search input by escaping these characters:

-const search = searchParams.get('search')?.trim();
+const search = searchParams.get('search')?.trim().replace(/[%_]/g, '\\$&');

This ensures that literal % and _ characters in user input are treated as text, not pattern wildcards.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
frontend/app/api/questions/[category]/route.ts lines 30-39: the search string
can contain '%' or '_' which LIKE/ILIKE treat as wildcards; before building the
ilike pattern, escape backslashes, percent and underscore in the trimmed search
input by prefixing them with a backslash (e.g., replace \ with \\ then % with \%
and _ with \_), then use that escaped value inside your `%${escaped}%` ilike
pattern so user-supplied literal % and _ are treated as text rather than
wildcards.


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 }
);
}
}
91 changes: 44 additions & 47 deletions frontend/components/q&a/TabsSection.tsx
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';
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unsafe type assertion for locale parameter.

useParams() returns Params where values can be string | string[] | undefined. The direct cast as string bypasses type checking and could lead to runtime issues if locale is missing or malformed.

🔎 Proposed fix with validation
   const params = useParams();
-
-  const locale = params.locale as string;
+  const locale = typeof params.locale === 'string' ? params.locale : 'en';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const params = useParams();
const locale = params.locale as string;
const params = useParams();
const locale = typeof params.locale === 'string' ? params.locale : 'en';
🤖 Prompt for AI Agents
In frontend/components/q&a/TabsSection.tsx around lines 28 to 30, the code
unsafely asserts locale with `as string`; instead validate the value returned
from useParams() (it can be string | string[] | undefined) by checking typeof
params.locale === 'string' and that it is not empty before using it, handle
string[] by selecting a sensible element (e.g., first) or rejecting it, and
provide a safe fallback (default locale or early return/error handling) so no
direct type assertion is used.


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') || '';

Expand All @@ -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) {
Expand All @@ -100,8 +107,9 @@ export default function TabsSection() {
setIsLoading(false);
}
}

load();
}, [active, currentPage, debouncedSearch]);
}, [active, currentPage, debouncedSearch, locale]);

const handleCategoryChange = (category: string) => {
setActive(category);
Expand All @@ -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}>
Expand All @@ -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')}
Expand Down
41 changes: 14 additions & 27 deletions frontend/db/schema/categories.ts
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),
]
);
3 changes: 2 additions & 1 deletion frontend/db/schema/index.ts
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';
Loading