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

View File

@@ -421,6 +421,22 @@ async function migrate() {
created_at TEXT NOT NULL
)
`);
// FAQ questions table
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS faq_questions (
id TEXT PRIMARY KEY,
question TEXT NOT NULL,
question_es TEXT,
answer TEXT NOT NULL,
answer_es TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
show_on_homepage INTEGER NOT NULL DEFAULT 0,
rank INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
`);
} else {
// PostgreSQL migrations
await (db as any).execute(sql`
@@ -790,6 +806,22 @@ async function migrate() {
created_at TIMESTAMP NOT NULL
)
`);
// FAQ questions table
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS faq_questions (
id UUID PRIMARY KEY,
question TEXT NOT NULL,
question_es TEXT,
answer TEXT NOT NULL,
answer_es TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
show_on_homepage INTEGER NOT NULL DEFAULT 0,
rank INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
}
console.log('Migrations completed successfully!');

View File

@@ -267,6 +267,20 @@ export const sqliteLegalPages = sqliteTable('legal_pages', {
createdAt: text('created_at').notNull(),
});
// FAQ questions table (admin-managed, shown on /faq and optionally on homepage)
export const sqliteFaqQuestions = sqliteTable('faq_questions', {
id: text('id').primaryKey(),
question: text('question').notNull(),
questionEs: text('question_es'),
answer: text('answer').notNull(),
answerEs: text('answer_es'),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
showOnHomepage: integer('show_on_homepage', { mode: 'boolean' }).notNull().default(false),
rank: integer('rank').notNull().default(0),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
// Site Settings table for global website configuration
export const sqliteSiteSettings = sqliteTable('site_settings', {
id: text('id').primaryKey(),
@@ -550,6 +564,20 @@ export const pgLegalPages = pgTable('legal_pages', {
createdAt: timestamp('created_at').notNull(),
});
// FAQ questions table (admin-managed)
export const pgFaqQuestions = pgTable('faq_questions', {
id: uuid('id').primaryKey(),
question: pgText('question').notNull(),
questionEs: pgText('question_es'),
answer: pgText('answer').notNull(),
answerEs: pgText('answer_es'),
enabled: pgInteger('enabled').notNull().default(1),
showOnHomepage: pgInteger('show_on_homepage').notNull().default(0),
rank: pgInteger('rank').notNull().default(0),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
// Site Settings table for global website configuration
export const pgSiteSettings = pgTable('site_settings', {
id: uuid('id').primaryKey(),
@@ -597,6 +625,7 @@ export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserS
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions;
// Type exports
export type User = typeof sqliteUsers.$inferSelect;
@@ -627,3 +656,5 @@ export type SiteSettings = typeof sqliteSiteSettings.$inferSelect;
export type NewSiteSettings = typeof sqliteSiteSettings.$inferInsert;
export type LegalPage = typeof sqliteLegalPages.$inferSelect;
export type NewLegalPage = typeof sqliteLegalPages.$inferInsert;
export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect;
export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;

View File

@@ -21,6 +21,7 @@ import paymentOptionsRoutes from './routes/payment-options.js';
import dashboardRoutes from './routes/dashboard.js';
import siteSettingsRoutes from './routes/site-settings.js';
import legalPagesRoutes from './routes/legal-pages.js';
import faqRoutes from './routes/faq.js';
import emailService from './lib/email.js';
const app = new Hono();
@@ -84,6 +85,7 @@ const openApiSpec = {
{ name: 'Media', description: 'File uploads and media management' },
{ name: 'Lightning', description: 'Lightning/Bitcoin payments via LNBits' },
{ name: 'Admin', description: 'Admin dashboard and analytics' },
{ name: 'FAQ', description: 'FAQ questions (public and admin)' },
],
paths: {
// ==================== Auth Endpoints ====================
@@ -1587,6 +1589,144 @@ const openApiSpec = {
},
},
},
// ==================== FAQ Endpoints ====================
'/api/faq': {
get: {
tags: ['FAQ'],
summary: 'Get FAQ list (public)',
description: 'Returns enabled FAQ questions, ordered by rank. Use ?homepage=true to get only questions enabled for homepage.',
parameters: [
{ name: 'homepage', in: 'query', schema: { type: 'boolean' }, description: 'If true, only return questions with showOnHomepage' },
],
responses: {
200: { description: 'List of FAQ items (id, question, questionEs, answer, answerEs, rank)' },
},
},
},
'/api/faq/admin/list': {
get: {
tags: ['FAQ'],
summary: 'Get all FAQ questions (admin)',
description: 'Returns all FAQ questions for management, ordered by rank.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'List of all FAQ questions' },
401: { description: 'Unauthorized' },
},
},
},
'/api/faq/admin/:id': {
get: {
tags: ['FAQ'],
summary: 'Get FAQ by ID (admin)',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'FAQ details' },
404: { description: 'FAQ not found' },
},
},
put: {
tags: ['FAQ'],
summary: 'Update FAQ (admin)',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
question: { type: 'string' },
questionEs: { type: 'string' },
answer: { type: 'string' },
answerEs: { type: 'string' },
enabled: { type: 'boolean' },
showOnHomepage: { type: 'boolean' },
},
},
},
},
},
responses: {
200: { description: 'FAQ updated' },
404: { description: 'FAQ not found' },
},
},
delete: {
tags: ['FAQ'],
summary: 'Delete FAQ (admin)',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'FAQ deleted' },
404: { description: 'FAQ not found' },
},
},
},
'/api/faq/admin': {
post: {
tags: ['FAQ'],
summary: 'Create FAQ (admin)',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['question', 'answer'],
properties: {
question: { type: 'string' },
questionEs: { type: 'string' },
answer: { type: 'string' },
answerEs: { type: 'string' },
enabled: { type: 'boolean', default: true },
showOnHomepage: { type: 'boolean', default: false },
},
},
},
},
},
responses: {
201: { description: 'FAQ created' },
400: { description: 'Validation error' },
},
},
},
'/api/faq/admin/reorder': {
post: {
tags: ['FAQ'],
summary: 'Reorder FAQ questions (admin)',
description: 'Set order by sending an ordered array of FAQ ids.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['ids'],
properties: {
ids: { type: 'array', items: { type: 'string' } },
},
},
},
},
},
responses: {
200: { description: 'Order updated, returns full FAQ list' },
400: { description: 'ids array required' },
},
},
},
},
components: {
securitySchemes: {
@@ -1716,6 +1856,7 @@ app.route('/api/payment-options', paymentOptionsRoutes);
app.route('/api/dashboard', dashboardRoutes);
app.route('/api/site-settings', siteSettingsRoutes);
app.route('/api/legal-pages', legalPagesRoutes);
app.route('/api/faq', faqRoutes);
// 404 handler
app.notFound((c) => {

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;