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:
@@ -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!');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
242
backend/src/routes/faq.ts
Normal 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;
|
||||
Reference in New Issue
Block a user