Skip to content

Commit 1134589

Browse files
(SP:2) [Frontend] Fix duplicated Q&A items after content updates (#330)
* fix(qa): prevent duplicate questions and improve cache invalidation * fix(qa): keep pagination totals consistent after deduplication
1 parent 2b028af commit 1134589

4 files changed

Lines changed: 133 additions & 13 deletions

File tree

frontend/app/api/questions/[category]/route.ts

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,47 @@ type QaApiResponse = {
2727
locale: string;
2828
};
2929

30+
function dedupeItems(items: QaApiResponse['items']) {
31+
const seenById = new Set<string>();
32+
const seenByText = new Set<string>();
33+
const unique: QaApiResponse['items'] = [];
34+
35+
for (const item of items) {
36+
if (seenById.has(item.id)) {
37+
continue;
38+
}
39+
40+
const textKey = `${item.locale}:${item.question.trim().toLowerCase()}`;
41+
if (seenByText.has(textKey)) {
42+
continue;
43+
}
44+
45+
seenById.add(item.id);
46+
seenByText.add(textKey);
47+
unique.push(item);
48+
}
49+
50+
return unique;
51+
}
52+
53+
function normalizeResponse(data: QaApiResponse, limit: number): QaApiResponse {
54+
const uniqueItems = dedupeItems(data.items);
55+
if (uniqueItems.length === data.items.length) {
56+
return data;
57+
}
58+
59+
const removed = data.items.length - uniqueItems.length;
60+
const total = Math.max(0, data.total - removed);
61+
const totalPages = Math.ceil(total / limit);
62+
63+
return {
64+
...data,
65+
items: uniqueItems,
66+
total,
67+
totalPages,
68+
};
69+
}
70+
3071
export async function GET(
3172
req: Request,
3273
ctx: { params: Promise<{ category: string }> }
@@ -59,9 +100,15 @@ export async function GET(
59100
const cached = await getQaCache<QaApiResponse>(cacheKey);
60101

61102
if (cached) {
62-
const response = NextResponse.json(cached);
103+
const normalizedCached = normalizeResponse(cached, limit);
104+
const response = NextResponse.json(normalizedCached);
63105
response.headers.set('Cache-Control', 'no-store');
64106
response.headers.set('x-qa-cache', 'HIT');
107+
108+
if (normalizedCached.items.length !== cached.items.length) {
109+
await setQaCache(cacheKey, normalizedCached);
110+
}
111+
65112
return response;
66113
}
67114

@@ -124,23 +171,21 @@ export async function GET(
124171
.limit(limit)
125172
.offset(offset);
126173

127-
const response = NextResponse.json({
174+
const payload = normalizeResponse(
175+
{
128176
items,
129177
total,
130178
page,
131179
totalPages,
132180
locale,
133-
});
181+
},
182+
limit
183+
);
184+
const response = NextResponse.json(payload);
134185
response.headers.set('Cache-Control', 'no-store');
135186
response.headers.set('x-qa-cache', 'MISS');
136187

137-
await setQaCache(cacheKey, {
138-
items,
139-
total,
140-
page,
141-
totalPages,
142-
locale,
143-
});
188+
await setQaCache(cacheKey, payload);
144189

145190
return response;
146191
} catch (error) {

frontend/db/seed-questions.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dotenv/config';
33
import { and, eq } from 'drizzle-orm';
44

55
import rawData from '../parse/questions.json';
6+
import { invalidateAllQaCache } from '../lib/cache/qa';
67
import { db } from './index';
78
import { categories, questions, questionTranslations } from './schema';
89

@@ -138,6 +139,13 @@ async function seedQuestions() {
138139
}
139140
}
140141

142+
try {
143+
const deleted = await invalidateAllQaCache();
144+
console.log(`[seed] Cleared Q&A cache keys: ${deleted}`);
145+
} catch (error) {
146+
console.warn('[seed] Failed to clear Q&A cache:', error);
147+
}
148+
141149
console.log('Questions seeded!');
142150
}
143151

frontend/lib/cache/qa.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { getRedisClient } from '@/lib/redis';
22

3+
const QA_CACHE_VERSION = 'v2';
4+
const QA_CACHE_TTL_SECONDS = 60 * 30;
5+
36
type QaCacheKeyInput = {
47
category: string;
58
locale: string;
@@ -19,7 +22,7 @@ export function buildQaCacheKey({
1922
const normalizedLocale = locale.toLowerCase();
2023
const searchKey = search?.trim() ? search.trim().toLowerCase() : 'all';
2124

22-
return `qa:category:${normalizedCategory}:locale:${normalizedLocale}:page:${page}:limit:${limit}:search:${searchKey}`;
25+
return `qa:${QA_CACHE_VERSION}:category:${normalizedCategory}:locale:${normalizedLocale}:page:${page}:limit:${limit}:search:${searchKey}`;
2326
}
2427

2528
export async function getQaCache<T>(key: string) {
@@ -46,7 +49,7 @@ export async function setQaCache<T>(key: string, value: T) {
4649
const redis = getRedisClient();
4750
if (!redis) return;
4851

49-
await redis.set(key, value);
52+
await redis.set(key, value, { ex: QA_CACHE_TTL_SECONDS });
5053
}
5154

5255
export async function invalidateQaCacheByCategory(category: string) {
@@ -82,7 +85,7 @@ export async function invalidateAllQaCache() {
8285

8386
do {
8487
const [nextCursor, keys] = await redis.scan(cursor, {
85-
match: 'qa:*',
88+
match: `qa:*`,
8689
count: 200,
8790
});
8891
cursor = Number(nextCursor);

frontend/lib/tests/q&a/questions-route.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@ vi.mock('@/db', () => ({
66
},
77
}));
88

9+
vi.mock('@/lib/cache/qa', () => ({
10+
buildQaCacheKey: vi.fn(() => 'qa:test:key'),
11+
getQaCache: vi.fn(async () => null),
12+
setQaCache: vi.fn(async () => undefined),
13+
}));
14+
915
import { GET } from '@/app/api/questions/[category]/route';
1016
import { db } from '@/db';
17+
import { setQaCache } from '@/lib/cache/qa';
1118

1219
type Builder = {
1320
from: ReturnType<typeof vi.fn>;
@@ -113,4 +120,61 @@ describe('GET /api/questions/[category]', () => {
113120
expect(data.totalPages).toBe(0);
114121
consoleSpy.mockRestore();
115122
});
123+
124+
it('deduplicates repeated question texts in response payload', async () => {
125+
const selectMock = db.select as ReturnType<typeof vi.fn>;
126+
const setQaCacheMock = setQaCache as ReturnType<typeof vi.fn>;
127+
128+
selectMock
129+
.mockReturnValueOnce(makeBuilder('limit', [{ id: 'cat-1' }]))
130+
.mockReturnValueOnce(makeBuilder('where', [{ count: 3 }]))
131+
.mockReturnValueOnce(
132+
makeBuilder('offset', [
133+
{
134+
id: 'q1',
135+
categoryId: 'cat-1',
136+
sortOrder: 1,
137+
difficulty: null,
138+
question: 'What is JavaScript?',
139+
answerBlocks: [],
140+
locale: 'en',
141+
},
142+
{
143+
id: 'q2',
144+
categoryId: 'cat-1',
145+
sortOrder: 1,
146+
difficulty: null,
147+
question: 'What is JavaScript?',
148+
answerBlocks: [],
149+
locale: 'en',
150+
},
151+
{
152+
id: 'q3',
153+
categoryId: 'cat-1',
154+
sortOrder: 2,
155+
difficulty: null,
156+
question: 'What is closure?',
157+
answerBlocks: [],
158+
locale: 'en',
159+
},
160+
])
161+
);
162+
163+
const req = new Request(
164+
'http://localhost/api/questions/javascript?page=1&limit=10&locale=en'
165+
);
166+
const res = await GET(req, {
167+
params: Promise.resolve({ category: 'javascript' }),
168+
});
169+
const data = await res.json();
170+
171+
expect(res.status).toBe(200);
172+
expect(data.items).toHaveLength(2);
173+
expect(data.items.map((item: { question: string }) => item.question)).toEqual(
174+
['What is JavaScript?', 'What is closure?']
175+
);
176+
expect(data.total).toBe(2);
177+
expect(data.totalPages).toBe(1);
178+
expect(setQaCacheMock).toHaveBeenCalledOnce();
179+
});
116180
});

0 commit comments

Comments
 (0)