feat: FAQ management from admin, public /faq, homepage section, llms.txt

- Backend: faq_questions table (schema + migration), CRUD + reorder API, Swagger docs
- Admin: FAQ page with create/edit, enable/disable, show on homepage, drag reorder
- Public /faq page fetches enabled FAQs from API; layout builds dynamic JSON-LD
- Homepage: FAQ section under Stay updated (homepage-enabled only) with See full FAQ link
- llms.txt: FAQ section uses homepage FAQs from API
- i18n: home.faq title/seeFull, admin FAQ nav

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-02-12 04:49:16 +00:00
parent 5885044369
commit 07ba357194
15 changed files with 1137 additions and 149 deletions

242
backend/src/routes/faq.ts Normal file
View File

@@ -0,0 +1,242 @@
import { Hono } from 'hono';
import { db, dbGet, dbAll, faqQuestions } from '../db/index.js';
import { eq, asc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow, generateId } from '../lib/utils.js';
const faqRouter = new Hono();
// ==================== Public Routes ====================
// Get FAQ list for public (only enabled; optional filter for homepage)
faqRouter.get('/', async (c) => {
const homepage = c.req.query('homepage') === 'true';
let query = (db as any)
.select()
.from(faqQuestions)
.where(eq((faqQuestions as any).enabled, 1))
.orderBy(asc((faqQuestions as any).rank), asc((faqQuestions as any).createdAt));
const rows = await dbAll<any>(query);
let items = rows;
if (homepage) {
items = rows.filter((r: any) => r.showOnHomepage === true || r.showOnHomepage === 1 || r.show_on_homepage === true || r.show_on_homepage === 1);
}
return c.json({
faqs: items.map((r: any) => ({
id: r.id,
question: r.question,
questionEs: r.questionEs ?? r.question_es ?? null,
answer: r.answer,
answerEs: r.answerEs ?? r.answer_es ?? null,
rank: r.rank ?? 0,
})),
});
});
// ==================== Admin Routes ====================
// Get all FAQ questions for admin (all, ordered by rank)
faqRouter.get('/admin/list', requireAuth(['admin']), async (c) => {
const rows = await dbAll<any>(
(db as any)
.select()
.from(faqQuestions)
.orderBy(asc((faqQuestions as any).rank), asc((faqQuestions as any).createdAt))
);
const list = rows.map((r: any) => ({
id: r.id,
question: r.question,
questionEs: r.questionEs ?? r.question_es ?? null,
answer: r.answer,
answerEs: r.answerEs ?? r.answer_es ?? null,
enabled: r.enabled === true || r.enabled === 1,
showOnHomepage: r.showOnHomepage === true || r.showOnHomepage === 1 || r.show_on_homepage === true || r.show_on_homepage === 1,
rank: r.rank ?? 0,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}));
return c.json({ faqs: list });
});
// Get one FAQ by id (admin)
faqRouter.get('/admin/:id', requireAuth(['admin']), async (c) => {
const { id } = c.req.param();
const row = await dbGet<any>(
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
);
if (!row) {
return c.json({ error: 'FAQ not found' }, 404);
}
return c.json({
faq: {
id: row.id,
question: row.question,
questionEs: row.questionEs ?? row.question_es ?? null,
answer: row.answer,
answerEs: row.answerEs ?? row.answer_es ?? null,
enabled: row.enabled === true || row.enabled === 1,
showOnHomepage: row.showOnHomepage === true || row.showOnHomepage === 1 || row.show_on_homepage === true || row.show_on_homepage === 1,
rank: row.rank ?? 0,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
},
});
});
// Create FAQ (admin)
faqRouter.post('/admin', requireAuth(['admin']), async (c) => {
const body = await c.req.json();
const { question, questionEs, answer, answerEs, enabled, showOnHomepage } = body;
if (!question || typeof question !== 'string' || !answer || typeof answer !== 'string') {
return c.json({ error: 'Question and answer (EN) are required' }, 400);
}
const now = getNow();
const id = generateId();
const allForRank = await dbAll<any>(
(db as any).select({ rank: (faqQuestions as any).rank }).from(faqQuestions)
);
const maxRank = allForRank.length
? Math.max(...allForRank.map((r: any) => Number(r.rank ?? 0)))
: 0;
const nextRank = maxRank + 1;
await (db as any).insert(faqQuestions).values({
id,
question: String(question).trim(),
questionEs: questionEs != null ? String(questionEs).trim() : null,
answer: String(answer).trim(),
answerEs: answerEs != null ? String(answerEs).trim() : null,
enabled: enabled !== false ? 1 : 0,
showOnHomepage: showOnHomepage === true ? 1 : 0,
rank: nextRank,
createdAt: now,
updatedAt: now,
});
const created = await dbGet<any>(
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
);
return c.json(
{
faq: {
id: created.id,
question: created.question,
questionEs: created.questionEs ?? created.question_es ?? null,
answer: created.answer,
answerEs: created.answerEs ?? created.answer_es ?? null,
enabled: created.enabled === true || created.enabled === 1,
showOnHomepage: created.showOnHomepage === true || created.showOnHomepage === 1 || created.show_on_homepage === true || created.show_on_homepage === 1,
rank: created.rank ?? 0,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
},
},
201
);
});
// Update FAQ (admin)
faqRouter.put('/admin/:id', requireAuth(['admin']), async (c) => {
const { id } = c.req.param();
const body = await c.req.json();
const { question, questionEs, answer, answerEs, enabled, showOnHomepage } = body;
const existing = await dbGet<any>(
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
);
if (!existing) {
return c.json({ error: 'FAQ not found' }, 404);
}
const updateData: Record<string, unknown> = {
updatedAt: getNow(),
};
if (question !== undefined) updateData.question = String(question).trim();
if (questionEs !== undefined) updateData.questionEs = questionEs == null ? null : String(questionEs).trim();
if (answer !== undefined) updateData.answer = String(answer).trim();
if (answerEs !== undefined) updateData.answerEs = answerEs == null ? null : String(answerEs).trim();
if (typeof enabled === 'boolean') updateData.enabled = enabled ? 1 : 0;
if (typeof showOnHomepage === 'boolean') updateData.showOnHomepage = showOnHomepage ? 1 : 0;
await (db as any).update(faqQuestions).set(updateData).where(eq((faqQuestions as any).id, id));
const updated = await dbGet<any>(
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
);
return c.json({
faq: {
id: updated.id,
question: updated.question,
questionEs: updated.questionEs ?? updated.question_es ?? null,
answer: updated.answer,
answerEs: updated.answerEs ?? updated.answer_es ?? null,
enabled: updated.enabled === true || updated.enabled === 1,
showOnHomepage: updated.showOnHomepage === true || updated.showOnHomepage === 1 || updated.show_on_homepage === true || updated.show_on_homepage === 1,
rank: updated.rank ?? 0,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
},
});
});
// Delete FAQ (admin)
faqRouter.delete('/admin/:id', requireAuth(['admin']), async (c) => {
const { id } = c.req.param();
const existing = await dbGet<any>(
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
);
if (!existing) {
return c.json({ error: 'FAQ not found' }, 404);
}
await (db as any).delete(faqQuestions).where(eq((faqQuestions as any).id, id));
return c.json({ message: 'FAQ deleted' });
});
// Reorder FAQs (admin) body: { ids: string[] } (ordered list of ids)
faqRouter.post('/admin/reorder', requireAuth(['admin']), async (c) => {
const body = await c.req.json();
const { ids } = body;
if (!Array.isArray(ids) || ids.length === 0) {
return c.json({ error: 'ids array is required' }, 400);
}
const now = getNow();
for (let i = 0; i < ids.length; i++) {
await (db as any)
.update(faqQuestions)
.set({ rank: i, updatedAt: now })
.where(eq((faqQuestions as any).id, ids[i]));
}
const rows = await dbAll<any>(
(db as any)
.select()
.from(faqQuestions)
.orderBy(asc((faqQuestions as any).rank), asc((faqQuestions as any).createdAt))
);
const list = rows.map((r: any) => ({
id: r.id,
question: r.question,
questionEs: r.questionEs ?? r.question_es ?? null,
answer: r.answer,
answerEs: r.answerEs ?? r.answer_es ?? null,
enabled: r.enabled === true || r.enabled === 1,
showOnHomepage: r.showOnHomepage === true || r.showOnHomepage === 1 || r.show_on_homepage === true || r.show_on_homepage === 1,
rank: r.rank ?? 0,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}));
return c.json({ faqs: list });
});
export default faqRouter;