Bonbon Dino — Q&A Board Checklist¶
Patterns for the public Q&A board feature — a thread-style contact form where customers post questions, the admin replies, and threads are visible (with optional per-question password gating).
If the client wants a regular contact form (no public thread, no admin replies visible), skip this file and use website-checklist.md §13 instead.
Reference impl: rushroad_storage/app/board/*, app/api/questions/*, components/board-*.
1. Single questions Table¶
What it does: One row per question. Replies live on the same row, not a separate replies table.
Why: A small business does not need threaded replies. The admin answers once. Keeping it in one row makes pagination, sorting, and the "answered/unanswered" filter trivial — and the schema fits on one screen.
Pattern:
// lib/schema.ts
export const questions = sqliteTable("questions", {
id: text("id").primaryKey(),
postNumber: integer("post_number").notNull(), // sequential, see §4
name: text("name").notNull(),
title: text("title").notNull(),
message: text("message").notNull(),
accessKey: text("access_key").notNull(), // HMAC hash, see §6
reply: text("reply"),
repliedAt: text("replied_at"), // ISO text, NOT int
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
});
All dates are ISO 8601 text strings — see db-and-deploy-checklist.md for the reasoning.
2. Public Form with Turnstile + Char Limits¶
What it does: /board/ask has a form with name, title, message, password (for later access), and a Turnstile widget. Server validates length on every field before insert.
Why: Storing user text without a length cap leads to DB row explosions when a bot posts the contents of a novel. Turnstile blocks the bots that would test that.
Char limits (sane defaults):
- name: 100
- title: 150
- message: 2000
- password: min 4
Mirror the limits on the client (live counter) and the server (hard 4xx reject). The server is the source of truth — the client just informs the user.
Turnstile build-time gotcha: NEXT_PUBLIC_TURNSTILE_SITE_KEY must be present at build time on Cloudflare (it's bundled into the JS, not read at runtime). See db-and-deploy-checklist.md §6 for the GitHub Actions secret pattern.
3. Pagination Always Visible¶
What it does: The page-number bar at the bottom of /board is rendered even when there is only one page of posts.
Why: When the board is new and has 8 posts, hiding pagination makes the page look broken — users don't see "Page 1" and wonder if there's more they can't reach. Always render at least the current page number.
Anti-pattern (do not do):
if (totalPages <= 1) return null; // looks broken on a small dataset
Pattern: Always render <BoardPagination current={page} total={totalPages} />; let the component show a single "1" pill when total=1.
Reference impl: rushroad_storage/components/board-pagination.tsx.
4. Sequential post_number (#1, #2, …)¶
What it does: Each new question gets a sequential post_number displayed in the list ("Post #47"). Numbers are stable across pagination — a question keeps its number forever.
Why: UUIDs are not human-friendly. "Post #47" is referenceable. Sequential numbers also give the customer a clue about how active the board is.
Pattern: On insert, compute MAX(post_number) + 1 in the same transaction. Schema column is non-null with no default — the API supplies the number.
const [{ next }] = await db.select({ next: sql<number>`COALESCE(MAX(post_number), 0) + 1` }).from(questions);
await db.insert(questions).values({ ..., postNumber: next });
Migration note: If you add post_number to an existing table, backfill in the same migration: UPDATE questions SET post_number = ROWID WHERE post_number IS NULL.
5. URL-as-State for Filters¶
What it does: The board's sort, search query, current page, and "open modal" state all live in the URL search params. No useState, no localStorage.
Why: Shareable links. Back/forward buttons work. SSR can pre-render the right page. Refreshing keeps the user where they were. No state desync between server and client.
Pattern:
/board?q=heater&sort=date&dir=desc&page=2&id=<post-uuid>
q — search query (server-side LIKE on title + message)
- sort=date|status + dir=asc|desc
- page — 1-indexed page number, 20 per page
- id — opens the detail modal for that question (see §6)
Helper to build links that preserve current params:
function withParams(extra: Record<string, string>) {
const next = new URLSearchParams(searchParams);
for (const [k, v] of Object.entries(extra)) next.set(k, v);
return `?${next}`;
}
6. Modal-by-Query-Param (NOT a separate route)¶
What it does: Clicking a board row opens a detail modal via ?id=<uuid>. The URL changes, the page does not.
Why first attempt failed: Original implementation was /board/[id] — a separate route. User said "this is overkill, I just want a pop-up." Separate routes also lose the surrounding context (search query, sort, page) unless you ferry every param through the link, and back-navigation jumps two history entries.
Pattern:
- Mount <BoardDetailModal /> at the bottom of /board/page.tsx.
- The modal reads useSearchParams().get("id") — if truthy, fetch + render; else hide.
- Closing the modal does router.push(withParams({ id: "" })) — preserves sort/page/search.
- Detail data fetched from GET /api/questions/[id] (returns locked metadata if no session/unlock cookie, full content otherwise).
Preserve all params on link click:
<Link href={`?${new URLSearchParams({ ...current, id: q.id })}`}>
{q.title}
</Link>
Reference impl: rushroad_storage/components/board-detail-modal.tsx, app/board/page.tsx.
7. Per-Question Access Keys (Optional)¶
What it does: Customers can password-protect their question. The board list shows the title + name, but the message body is hidden behind a password gate until the original poster (or the admin) unlocks it.
Why: Customers sometimes ask about pricing, payment, account-specific info they don't want public.
Pattern (HMAC, not bcrypt):
- Customer enters a 4+ char password on the public form.
- Server computes accessKey = HMAC_SHA256(password, JWT_SECRET), stores the hex digest.
- To unlock: client sends password to POST /api/questions/[id]/unlock. Server computes the HMAC and compares timing-safely. On match, set an unlock-<id> cookie at path / (NOT /board — the modal fetches from /api/... and the cookie won't be sent otherwise).
- Admin session bypasses unlock entirely — getSession() truthy → return full content.
Why HMAC instead of bcrypt: The access key is a derived secret, not a stored password. We need to derive the same hash from the password every time to compare — bcrypt's salt makes that impossible. HMAC with the server's secret as the key gives a deterministic, fast comparison.
Unlock cookie path gotcha: First implementation used path: "/board" so the cookie only sent on board page requests. The modal's API call is to /api/questions/[id], so the cookie was never attached and the unlock "worked" but the next fetch acted unlocked-but-returned-locked. Use path: "/".
Reference impl: rushroad_storage/lib/access.ts, app/api/questions/[id]/unlock/route.ts.
8. Admin Reply on the Same Modal¶
What it does: The detail modal is shared between public viewers and the admin. When the viewer is the admin, the modal shows a reply textarea + Save/Delete buttons inline.
Why: No separate admin board UI. The admin reads the question in the same modal the customer would see, with extra controls when authenticated.
Pattern:
- Modal receives isAdmin: boolean prop (set from getSession() server-side).
- If admin: show reply form (or current reply with edit button), Save calls POST /api/questions/[id]/reply, Delete calls DELETE /api/questions/[id]/reply.
- After save/delete: router.refresh() re-renders the server component with the new reply.
9. Empty-DB Graceful Render¶
What it does: When the questions table is empty (or the DB connection itself fails), the board renders with a friendly empty state instead of a 500.
Why: New site with no posts should not crash. Transient Turso outages should not show the user a stack trace.
Pattern:
let rows: Question[] = [];
try { rows = await db.select().from(questions).all(); }
catch (err) { console.error("board fetch failed", err); }
// render rows.length === 0 → "No questions yet, be the first to ask"
Reference impl: rushroad_storage/app/board/page.tsx (commit 9e2b544).
10. Selectable Row Text¶
What it does: Users can select and copy the question title from the board list. Clicking on row whitespace navigates; selecting text does not.
Why: A "row link" that wraps the entire row in <Link> makes text un-selectable — the click event hijacks the mouseup. Customers want to copy a question title to share with a friend.
Pattern: Use a <Link> only on the title text, not the whole row. Use a <div onClick={...}> for whole-row navigation that checks window.getSelection() and only navigates if no selection is active.
Reference impl: rushroad_storage/components/board-row.tsx (commit 9ae2367).
11. Notify Admin on New Question (Optional)¶
What it does: When a new question is submitted, fire an email to the admin's email address.
Pattern: Fire-and-forget after the insert returns; never block the user's response. See email-checklist.md §3.
Subject line should include the business name so the admin can identify the source when they manage multiple sites — e.g., "[<Business Name>] New question: <title>" not just "New question".
12. Mobile Table Overflow¶
What it does: On narrow phones the board "table" (it's really a stacked list at <md, a grid at md+) can overflow horizontally if a long word in the title doesn't wrap.
Why bug appeared: Android Chrome treats certain CSS white-space defaults differently from iOS Safari. A word-break: keep-all somewhere up the tree (often from a Tailwind preset) prevented long words from breaking.
Pattern:
- Wrap the board list in overflow-x-auto.
- Apply break-words (i.e., word-break: break-word) to the title cell.
- Test on a real Android device, not just Chrome DevTools mobile emulation — the bug doesn't always reproduce in emulation.
Quick Checklist — "Is the Q&A board done?"¶
- [ ] Single
questionstable with ISO text dates - [ ] Public
/board/askform: Turnstile + 100/150/2000 char limits + min-4 password - [ ]
NEXT_PUBLIC_TURNSTILE_SITE_KEYpassed at build time (see db-and-deploy-checklist.md §6) - [ ] Pagination always rendered, even on a single page
- [ ] Sequential
post_numbercomputed viaMAX(post_number) + 1 - [ ] All filter state (
q,sort,dir,page,id) lives in URL search params - [ ] Detail view is a modal opened via
?id=<uuid>, NOT a/board/[id]route - [ ] Closing the modal preserves sort/page/search params
- [ ] Per-question access keys use HMAC (not bcrypt), unlock cookie at
path: "/" - [ ] Admin reply uses the same modal as public view, gated by
isAdminprop - [ ] Board catches DB errors and renders an empty state, not a 500
- [ ] Row title is its own
<Link>so text can be selected and copied - [ ] Admin notification email subject prefixed with business name
- [ ] Mobile:
overflow-x-autowrapper +break-wordson title; tested on real Android
How to extend this file¶
- Append new sections at the bottom — do NOT inline-edit existing sections.
- Match the house style: numbered section, What it does / Why / Pattern, code snippet where useful.
- Reference existing sections when relevant ("see website-checklist.md §17", "see admin-panel-checklist.md §3").