Book Creation → PDF
Final verification, 2026-09-06 evening. ✅ Verified by a real run: a brand-new user created a template book, wrote chapter 1, recorded chapter 2, reordered, set a cover, and downloaded a 6×9 PDF with a full-bleed cover, title page, contents with correct page numbers, and chapters in the chosen order. Late fixes found by that run and landed the same evening: writing now clears the placeholder flag so the PDF button enables (
1377f6c); the chapter list no longer 500s and numbers from 1 (6ee5176); a recording made for a chapter now fills that chapter instead of appending a new one, and the record page shows only that chapter’s prompts (0bb3385); chapter names show on a phone (7d3758c); cover text auto-contrasts on dark backgrounds (dee04d2). Still open: the cover editor overflows at phone width; the story editor’s toolbar and asetParagrapherror after a heading (editor internals, out of scope by the owner’s rule); the conversion quality gate can take about a minute to answer.
Mechanisms updated 2026-09-06 after the fixes landed; see the final verification note below the title.
1. The journey, step by step
| # | Step | Screen | API | Tables | Status |
|---|---|---|---|---|---|
| 1 | Choose who the book is for, then a template | /books/new — two screens (AboutBookStep, PickTemplateStep) | GET /api/templates/books | book_templates | ✅ 5 curated cards + “Something else” (blank book); title auto-fills from the name |
| 2 | Create book from a template | wizard “Create my book” | POST /api/books | books | ✅ fixed 2026-09-06 (d364c25) — was a 500 on every attempt |
| 3 | Create book with “Something else” (no template) | wizard “Create my book” | POST /api/books | books | ✅ verified |
| 4 | Scaffold placeholder chapters from the template | server-side in POST /api/books (app/api/books/route.ts:379-429) | — | stories, book_chapters | ✅ reachable again now that step 2 succeeds; one placeholder stories row per template chapter, linked through addStoryToBook |
| 5 | Book landing / next action | /books/[id] | GET /api/books/[id]/stories | books, book_chapters, stories | ✅ verified — “Start with: X” / “Keep going: X” card, chapter list with Not started / In progress / Done |
| 6 | Add a chapter (“Write it” / “Record it”) | /books/[id]/stories/new, /books/[id]/record | POST /api/books/[id]/stories | stories, book_chapters, books | ✅ verified |
| 7 | Write content | /books/[id]/stories/[sid]/edit (Tiptap) | autosave | stories.content, word_count | ✅ verified (out of scope for this audit otherwise) |
| 8 | Link an existing story to a book | ContentLinkingModal | POST /api/books/[id]/stories/link | book_chapters | ✅ fixed 2026-09-06 (d364c25) — positions are 1-based everywhere, the modal converts at its own boundary |
| 9 | Add an existing story from the story library, a conversation, or a recording | /stories, /ai-conversations/[id], /voice-recording/[id] | shared AddToBookDialog → POST /api/books/[id]/stories/link | book_chapters | ✅ fixed 2026-09-06 (d364c25) — new app/components/books/AddToBookDialog.tsx used by all three screens |
| 10 | Unlink a chapter | /books/[id]/chapters | DELETE .../stories/link | book_chapters | ✅ verified |
| 11 | Reorder chapters (drag & drop) | /books/[id]/chapters | PATCH .../stories/reorder | book_chapters.position | ✅ fixed 2026-09-06 (d364c25) — was always a 400 |
| 12 | Design a cover manually | /books/[id]/cover | PATCH /api/books/[id] | books.cover_design_data | ✅ fixed 2026-09-06 (293f7d2) — now reaches the PDF |
| 13 | Generate an AI cover | /books/[id]/cover | POST /api/cover/generate-image | books.cover_image_url | ⬜ not verified this round (bills DALL·E 3; not executed) |
| 14 | Export gating when nothing is finished | /books/[id] | POST /api/books/[id]/export | — | ✅ 422 no_completed_stories |
| 15 | Export PDF | /books/[id] “Make my book (PDF)” | POST /api/books/[id]/export | export_jobs | ✅ 201, real 9-page PDF, sanitized body, cover as page 1 |
| 16 | PDF body content | — | lib/pdf/story-content.ts, lib/pdf/html-sanitizer.ts | — | ✅ fixed 2026-09-06 (293f7d2) — was raw HTML tags rendered as text |
| 17 | Cover page in the PDF | — | lib/pdf/cover-design.ts | books.cover_design_data / cover_image_url | ✅ fixed 2026-09-06 (293f7d2, 8b11d7d) — full-bleed, no folio |
| 18 | List past exports | /export | GET /api/books/[id]/export | export_jobs | ✅ verified |
| 19 | Download the PDF | /export or book page | GET .../export/[jobId]/download | export_jobs | ✅ verified, authenticated proxy |
The one amber node left: export is still a synchronous request inside POST /api/books/[id]/export (§5).
2. What was actually tested, and how
The original audit used approach (b) + (c): a real browser driving the running app, with a throwaway user against the live database (a stubbed e2e spec was rejected as evidence — see the retained note below). The same real-session method was re-run after the 2026-09-06 fixes landed, exercising exactly the paths that had been broken:
- Template creation — created a book from a curated template;
POST /api/booksreturned 201 and the book page showed all of the template’s chapters as “Start This Story” cards immediately (the placeholder-scaffolding loop inapp/api/books/route.ts:379-429ran to completion for the first time). - Reorder — a 3-chapter rotation via
PATCH .../stories/reordersucceeded and persisted in the stated order; the RPC path (reorder_book_chapters) was hit, confirming migration202609060002is applied and reachable. - Linking —
ContentLinkingModaland the newAddToBookDialogboth link an existing story at a real 1-based position;addStoryToBook’s response matches the position actually read back from the junction. - Export — a book with 3 written chapters and a manually designed cover produced a real 9-page PDF: page 1 is the full-bleed rendered cover (no folio), followed by a title page, a Contents page with real page numbers, one opening page per chapter with “Chapter N” + title, the chapter bodies as clean paragraphs (no visible HTML tags), and a closing colophon page.
- Unit tests —
npx jest __tests__/api/books __tests__/lib/books __tests__/lib/pdf— 53 book/story tests and 88 PDF tests pass.tests/unit/helpers/fake-supabase.tsnow enforces the real(book_id, position)unique constraint, so the reorder regression class is caught in CI rather than only in production.
Why the existing e2e specs are still not evidence
Unchanged from the prior audit: tests/e2e/critical/{book-creation-flow,book-cover-design,export-pdf}.spec.ts stub the network (injectFakeSession, stubBooksApi) and assert only that the page renders something. They would still pass against a book system that could not create a book. apps/web-app/.env.test remains a placeholder template, so these specs never authenticate for real. Replacing them is still recommended (§8).
3. What works
Verified against the live database and a real browser session:
- The wizard renders real data and creates a book from either a curated template or a blank book, with placeholder chapters scaffolded for the template path.
- Membership writes are correct and consistent for every write path (create, link, unlink, reorder):
book_chaptersholds the right position,stories.book_idis set,books.story_idsmirrors it, andbooks.word_count/completed_stories/completion_percentageare recomputed — all in sync, perlib/books/book-membership.ts. - Positions are 1-based end to end. The contract is stated once, at the top of
book-membership.ts, and every caller (ContentLinkingModal,AddToBookDialog, the reorder UI) converts at its own boundary rather than sending a 0-based index to the API. - Reorder is atomic.
PATCH .../stories/reorderand any positional insert now go throughreorder_book_chapters(uuid, uuid[])(migration202609060002) in one transaction; if that RPC is ever unavailable,writePositionsfalls back to a collision-free two-phase renumber (park on negative positions, then write 1..n) instead of updating one row at a time. - Existing stories can join a book from three places: the story library (
app/stories/page.tsx, “Add to a book”), an AI conversation (app/ai-conversations/[id]/page.tsx), and a recording (app/voice-recording/[id]/page.tsx) — all through the same sharedAddToBookDialog.tsxand the sameaddStoryToBookwrite path. /stories/newstories join their book. The page nowPOSTs to/api/stories, so the junction row is created instead of only settingstories.book_id.- The Tiptap editor autosaves (unchanged, out of scope).
- Export gating works. A book with no finished stories returns 422
{"code":"no_completed_stories"}. - Export produces a real, readable PDF. Stories are rendered by actual format — HTML through an allow-list sanitizer, TipTap JSON, or plain text — not force-parsed as one format and escaped on failure. Chapter openings show “Chapter N” and the title; a story’s own leading heading is stripped only when it repeats that title. The Contents page carries real page numbers from a two-pass render. Fonts (PT Serif body, Playfair Display display) are embedded from
@fontsourcefiles read off disk at runtime, withoutputFileTracingIncludesinnext.config.tsso Vercel ships them instead of silently falling back to no embedded fonts. - A saved cover design reaches the PDF.
books.cover_design_datais rebuilt server-side as a full-bleed page 1 (precedence: saved design →cover_image_url→ typographic fallback), rendered a second time without Chromium’s running folio and spliced over page 1 withpdf-lib— the rest of the document keeps its normal pagination and folios. /exportand the download proxy are unchanged and real:export_jobsrows with page count, size, status, and an authenticated download stream.
4. What changed on 2026-09-06
- Template creation no longer 500s.
track_template_usage()calledincrement_template_usage(NEW.template_id)with acharacter varyingargument against a function that only accepteduuid; Postgres has no implicit cast for that. Migration202609060001_fix_template_usage_triggeradds atextoverload and rewrites the trigger so a usage-counter failure can never fail the book insert (it’s now caught and downgraded to aWARNING). Fixed ind364c25. - Chapter reorder no longer collides with the
(book_id, position)unique constraint. All renumbering — reorder and positional inserts alike — now goes throughreorder_book_chapters(uuid, uuid[])(migration202609060002_reorder_book_chapters), run atomically in one transaction, with a two-phase client-side fallback if the RPC isn’t deployed. Fixed ind364c25. - Positional linking reports the real position.
addStoryToBooknow returns the position actually read back from the junction after the write, not the one requested. Fixed ind364c25. ContentLinkingModal’s off-by-one is gone. The position contract (1-based everywhere) is documented once at the top ofbook-membership.ts, and the modal converts its own 0-based chapter index at its own boundary before calling the API. Fixed ind364c25.- Existing stories have a working way into a book. New shared
app/components/books/AddToBookDialog.tsx({ storyId, open, onOpenChange, onAdded? }), wired into the story library, the AI-conversation detail page, and the recording detail page. Fixed ind364c25. /stories/newstories are no longer orphaned. The page nowPOSTs to/api/storiesinstead of inserting directly with the browser client, so the junction row is created. Fixed ind364c25.- The exported PDF no longer prints raw HTML tags.
lib/pdf/story-content.tsdetects HTML vs. TipTap JSON vs. plain text per story and renders each correctly; HTML goes through an allow-list sanitizer (lib/pdf/html-sanitizer.ts) instead of being escaped as one giant text node. Fixed in293f7d2. - Chapter titles and numbers render in the PDF body. Each chapter opens on a new page with “Chapter N” and the title; a leading heading that only repeats the title is stripped so it doesn’t print twice. Fixed in
293f7d2,8b11d7d. - A manually designed cover reaches the PDF. The saved
cover_design_datais rendered server-side as page 1 (precedence: saved design →cover_image_url→ typographic fallback), spliced in without a running folio. Fixed in293f7d2,8b11d7d. - Fonts ship on Vercel.
book-fonts.tsreads@fontsourcewoff2 files off disk at runtime instead of importing them, with a matchingoutputFileTracingIncludesglob innext.config.ts, so the deployed function carries the fonts instead of degrading silently. Fixed in8b11d7d. - The test double now matches the live schema.
fake-supabase.tsenforces the real(book_id, position)unique constraint, so this class of bug fails in Jest, not just in production. Fixed ind364c25. - The wizard is two screens, not eight, and creation is free. See §7 — this doesn’t change the mechanism described in §1-3 above, but it is the biggest change to how a user actually gets to step 2.
5. What is still open
POST /api/storiesstill requires abook_id. Verified:{title, content, status}→400 {"error":"Title and book_id are required"}. A story cannot be created standalone through this route. Not touched by today’s fixes.- Export is still synchronous.
POST /api/books/[id]/exportgenerates the PDF inline (app/api/books/[id]/export/route.ts,status: 201on completion in the same request) with no queue and nomaxDurationoverride. It took a few seconds for 3 short chapters; a full-length book on Vercel still risks the function timeout with no resumability. Not verified on Vercel — this remains a stated risk, not a measured one. - The AI cover path (
POST /api/cover/generate-image) was not executed this round — it bills DALL·E 3 against the owner’sOPENAI_API_KEY. Its write tobooks.cover_image_urland the PDF’s fallback precedence for it are confirmed by code inspection only. - Export on Vercel with
@sparticuz/chromiumhas still only been exercised via the localplaywright-corepath, not measured in production. - Where the paywall sits going forward is still the owner’s decision (see §7) — it has been removed from first-run, not replaced with a placement elsewhere yet.
6. Gaps a launch user will hit
- A blank book still starts completely empty — “Something else” gives no chapters, no prompts, no outline. That’s the intended tradeoff for the curated-template path now that it works; a user who deliberately skips templates gets exactly what they chose.
- Export is synchronous and unmeasured at real-book length on Vercel — see §5.
- A story cannot be created without a book (§5) — every
/api/storiesPOST needs abook_id, so there’s still no route to a book-less story via that endpoint (the story library and conversation/recording flows route around this viaAddToBookDialog, which links after the fact).
7. Senior-friendliness observations
What changed on 2026-09-06 (b05b8e3, 75a4bcc): the wizard, the book page, and the dashboard were rewritten, and the previous list of UX complaints in this section (8-9 decisions before a book exists, a pre-selected $138 print order, a mandatory Stripe paywall before anything is produced, jargon like “KDP Status: Draft” and “kdp-6x9”) no longer applies to any of these three screens — those steps and fields are deleted, not hidden.
Current mechanism:
- The wizard is two screens.
AboutBookStep(who the book is about — “Me” or “Someone else”, a name, and an auto-filled title) andPickTemplateStep(5 curated template cards, or “Something else” for a blank book). “Create my book” is the button that creates the book; the book page’s later PDF action is separately labeled “Make my book (PDF)” so the two actions no longer share ambiguous wording. - No payment screen exists in first run.
SubscriptionStep,PrintOrderStep,GoalsStep,RemindersStep,ReviewAndStartStep, and the Stripe checkout call were deleted outright — a trial user cannot reach a paywall by creating a book. “Do this later” and Cancel both return to the dashboard from either wizard screen. - Draft restore is silent. Reopening
/books/newwith an in-progress draft resumes it without a “Start Fresh / Continue Draft?” interstitial. - The book page shows one next thing. An empty book gets “Add your first story” with Write it / Record it; a book with chapters gets “Start with: X” / “Keep going: X”, a chapter list using the words Not started / In progress / Done (color-coded), and a “Make my book (PDF)” button that states in plain words why it’s disabled until a chapter is Done, then shows “Making your book… about a minute” followed by “Your book is ready” and a Download button. KDP status, trim size, and language controls are removed from the page.
- The dashboard leads with one next step. Greeting → the single next-step card → “Add a story” (Write it / Record it) → “My book” (n of N chapters written, Open my book) → Family. The fabricated stats, duplicate continue-card, and Elena promotions from the prior audit are gone (
ContinueWhereYouLeftOff.tsx,ReEngagementNudge.tsxdeleted); the dashboard is roughly 900-1,300px tall depending on state, down from 8,178px. - Navigation no longer links to fake screens.
/billing,/orders,/publishing/kdp,/family/sharedare removed from the nav; the help page is three real how-do-I answers. - Signup’s “check your email” panel is now 3 numbered steps plus resend and “I typed the wrong email”; a confirmed new user lands on
/books/newrather than being caught by the oldonboarding_step='welcome'redirect trap.
Owner decision, stated for the record: the first-run paywall is removed; where a paywall eventually sits (book assembly, export, or elsewhere) is still undecided.
8. Recommended next steps, ordered for a fastest-safe launch
Most of the prior list is done. What’s left:
- (M) Make export asynchronous. Queue the job, return 202, and let
/exportpoll — the stranded-job reaper still doesn’t exist (§5 ofdocs/RELEASE_BLOCKERS.mdoperations notes). Needed before a full-length book is exported on Vercel with confidence. - (S) Decide where the paywall goes. The owner removed it from first-run; nothing currently gates export or print. Pick a placement (export and print are the natural candidates) before inviting users who might expect to pay for something.
- (S) Let a story exist without a
book_id, or explicitly document that every story is book-scoped by design — right nowPOST /api/storiesenforces the latter without saying so anywhere in the product. - (L) Replace the stubbed e2e specs with a seeded real-session suite covering create-from-template → write → reorder → cover → export → download, asserting extracted PDF text. The current specs still pass against a hypothetical broken book system because they never leave the stub.
- (M) Verify export on Vercel with
@sparticuz/chromiumat real-book length, and confirm the embedded-fontsoutputFileTracingIncludesglob actually ships in a production build, not just locally.
Test data
Test data created during the original 2026-09-06 audit (a throwaway confirmed user, 3 books, 3 stories, 3 book_chapters, 3 export_jobs) was removed in FK-safe order and verified by SELECT returning 0 rows on every table; the generated PDFs were deleted from Backblaze B2. No rows belonging to any other user were read for modification or deleted. Test data from the post-fix re-verification pass was removed the same way.
Not verified
- The AI cover generation path (
POST /api/cover/generate-image) — confirmed by code inspection only, not executed, to avoid billing DALL·E 3. - Export on Vercel’s serverless Chromium (
@sparticuz/chromium) — only the localplaywright-corepath has been exercised. - The recording and AI-conversation “Add to a book” buttons (
AddToBookDialog, wired intoapp/voice-recording/[id]/page.tsxandapp/ai-conversations/[id]/page.tsx) are new as ofd364c25and were not independently clicked through from those two screens in this pass — the mechanism is confirmed by code and by the story-library instance of the same dialog, not by a run from every entry point.