Skip to content

feat(giver): personal letter authoring step - #15

Open
xBalbinus wants to merge 8 commits into
mainfrom
feat/personal-letter
Open

xBalbinus wants to merge 8 commits into
mainfrom
feat/personal-letter

Conversation

@xBalbinus

Copy link
Copy Markdown

Summary

Adds a /onboarding/letter step where the giver writes the personal note their recipient sees first on /r/:token/letter. Inserts between delivery and send in the onboarding flow.

Changes

  • Migration 005: ALTER TYPE onboarding_step ADD VALUE 'letter' BEFORE 'send'. (gifts.personal_message already exists from migration 004.)
  • server/src/db/schema.ts + gift-store.ts + gifts.ts: extend OnboardingStep and the stepSchema with letter.
  • web/app/onboarding/letter/page.tsx: new page modeled on /onboarding/recipient: textarea (4000 char cap) + Whisper voice button. Optional — Skip routes to send and falls back to the generic letter on the parent side. Placeholder pre-fills with the canonical "I made you something. Open whenever you have a quiet minute."
  • delivery/page.tsx: routes to /onboarding/letter instead of /onboarding/send.
  • _lib/sync.ts: patchGiftFields now propagates personalMessage so the snapshot sync persists it to the backend column.
  • _lib/state.ts: ONBOARDING_STEPS gains letter before send.

Drive-by

  • server/src/lib/email.test.ts had pre-existing typeof fetch cast errors that surfaced on the current type-check — fixed via as unknown as typeof fetch and a more inferable capture-state shape. 71/71 server tests still pass.

Test plan

  • Migration applied cleanly to local + e2e DBs.
  • bun run --filter '*' type-check clean.
  • bun run --filter '*' lint clean (7 pre-existing web warnings).
  • bun --cwd server test with INTEGRATION=1 → 71/71 pass.
  • Browser smoke: delivery → letter → send routes correctly, personalMessage lands in local state, headline + placeholder personalize to the chosen intent.
  • (post-merge) full E2E from /onboarding through send + check parent-side /r/:token/letter renders the giver's letter once the recipient frontend is wired to the API envelope.

ahmedpanju and others added 7 commits May 2, 2026 17:23
Builds the full parent-side experience for the recipient of an Ember gift,
plus a third Claude topic for the in-journal "talk it through" surface.

Frontend (web/app/r/[token]/*) — all token-scoped, all persists to
localStorage under ember:parent:{token}:v1, resumes mid-flow:
- welcome → letter → how-it-works → account ("Save your space")
- /home (Today) — composer + 3 starting points (Pick a prompt /
  Talk it through with Ember / Record your voice). Bottom tabs:
  Today / Journal / Prompts / Share
- /journal — List (grouped by month), Calendar (interactive — tap day),
  Media. "+ New entry" FAB
- /journal/new — dedicated composer screen (FAB target + prompt-tap target,
  takes ?promptId=)
- /prompts — All / Used filter; tap unused prompt → composer with prompt
- /ai — Claude chat (parent-reflect tone). "Save as journal entry" distills
  the conversation into prose in the parent's first-person voice
- /voice — dedicated voice note (record → Whisper → editable transcript →
  save with durationSeconds)
- /share — sharing settings + "Share what I've written so far" with
  confirm modal. Sharing is one-time → archives the journal
- /share/when — How and when picker (when-ready / legacy / date /
  milestone). Date and milestone require a specific calendar date
- /share/done — "She has it." success ack

Reusable components:
- EntryComposer — text + photo + voice (Whisper) used on Today,
  /journal/new, and indirectly via /prompts
- BottomTabs — 4-tab nav with Today / Journal / Prompts / Share
- AvatarMenu — avatar circle → dropdown → Sign out (clears all
  ember:parent:* + ember:onboarding:v1 + cookie, routes to /login)

Archived state — once sharing.sharedAt is set:
- /home shows "Your journal is shared." instead of composer
- /journal shows Archived banner, FAB hidden
- /prompts shows read-only banner, taps disabled
- /journal/new + /voice redirect to /journal
- /ai save button disabled
- /share replaces options with the Archived snapshot card

Server (server/src/routes/ai.ts):
- Adds 3rd topic "parent-reflect" to /ai/converse and /ai/summarize.
- Different Claude system prompt: warm, low-pressure, ok with silence,
  never simulates anyone in the parent's life. Summarizer writes in
  first-person from the parent's voice for direct insertion as an entry.

Docs:
- BACKEND_HANDOFF_V2.md — comprehensive page-by-page spec covering every
  child + parent route, every API endpoint to build, full data model,
  file storage, email, sharing/archive flow, migration plan from
  localStorage. Supersedes BACKEND_HANDOFF.md (which only covered child
  onboarding); the original is left in place for context.

Notes:
- Parent flow is currently localStorage-only — the recipientRoutes that
  landed on main aren't wired yet. BACKEND_HANDOFF_V2 §10 documents the
  migration plan.
- /login was intentionally not touched — main's existing login (with
  real backend wiring) stands.
- Photos: inline data URLs (4MB cap) pending S3.
- Audio: transcript only on the parent side; raw audio storage is on
  the backend handoff list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- All 16 add/add conflicts on web/app/r/[token]/** resolved by taking
  main's EmberChrome-polished versions. The recipient-flow-v1 _lib/
  files (entries, prompts, sharing, state) come in from this branch
  and are the missing imports main's polished pages depend on.
- Fix two pre-existing unresolved git stash markers on main in
  web/app/onboarding/questions/{write,review}/page.tsx — type-check
  was failing on main itself before this merge.
… login

Implements every endpoint BACKEND_HANDOFF_V2.md §5.5/§5.1 calls out for
the parent (recipient) side, plus the schema to back them.

Schema (migration 004):
- journal_entries — free-form parent journal records (text/photo/voice/
  ai/free-write/prompt). Snapshots promptText at write time so giver
  edits don't mutate what the parent saw.
- sharing — one-per-recipient config + one-time sharedAt + snapshot
  count. Once sharedAt is set, the journal is locked.
- recipients.password_hash + account_created_at for the parent login.
- gifts.personal_message for the giver-authored letter on /r/:token/letter.

Routes (server/src/routes/recipient.ts):
- GET /r/:token returns the full envelope (giver, recipient, prompts,
  sharing, archived, personalMessage) the parent UI needs.
- GET /r/:token/prompts derives prompts from the giver's questions.
- Journal entries CRUD: GET/POST/PATCH/DELETE /r/:token/entries[/:eid]
  + multipart photo + audio uploads. Audio upload stores raw + Whisper
  transcript; transcription failure preserves existing text rather than
  blanking it.
- Sharing: GET/PATCH /r/:token/sharing + idempotent POST /r/:token/share.
- Recipient account: POST /r/:token/account sets credentials;
  POST /auth/recipient/login (in auth.ts) verifies + returns the
  access token + redirectTo.
- Write enforcement: entries POST/PATCH/DELETE + photo/audio upload +
  PATCH /sharing all return 409 once the journal is shared.

Legacy per-question response endpoints (/questions/:qid/{text,voice,
photo}) remain for the older UI surface — the new parent flow uses
/entries instead.

Tests (server/src/routes/recipient-flow.integration.test.ts):
- 20 INTEGRATION-gated tests covering the envelope, prompts, all four
  CRUD verbs, photo + audio upload happy + Whisper-failure paths,
  sharing patch + share + idempotency + lock enforcement on writes
  and on sharing config, account creation + login + wrong-password +
  unknown-email rejection.

End-to-end verified by curling a fresh local Postgres + bun-served
instance through the full flow: create gift → patch + add 3 questions
→ send → recipient envelope → entries (free-write + prompt + photo +
voice) → sharing config → account → login → share → 409 on writes.
CI green-list:
- Test workflow now runs `bun run --filter server db:migrate` before
  `bun run --filter server test`. Without it, the Postgres service
  starts empty, every authContext-dependent route 500s on `relation
  "users" does not exist`, and ~30 tests fail before any of them get
  to assert. (.github/workflows/test.yml)

Test fixes:
- auth-login.integration.test.ts: assert `me.user.id` is a non-empty
  string instead of matching `/^usr_/`. The xors API moved to bare
  UUIDs; the `usr_` prefix the test was pinned to no longer exists.
- auth-login.integration.test.ts: stash + clear `TEST_USER_EMAIL` for
  the duration of this file. The other integration tests
  (recipient.*, recipient-flow.*) set that env in beforeAll to bypass
  the real session lookup, and the var is process-global — left set,
  authContext.maybeTestUser() short-circuits and the real-session
  /auth/me check returns the wrong user. Restored on afterAll.

Lint fixes:
- server: noAssignInExpressions in auth-login.test.ts:45 +
  messages.test.ts:79 — replaced `() => (globalThis.fetch = realFetch)`
  with explicit block bodies.
- web: 8 `useIterableCallbackReturn` — replaced
  `getTracks().forEach((t) => t.stop())` with `for...of` loops, plus
  one localStorage cleanup pattern.
- web: 3 `useExhaustiveDependencies` (about/why/parent-reflect AI
  pages) — wrapped each `converse()` in useCallback so the open-on-
  mount effect's deps stay stable.
- web: noRedundantAlt — "Question photo preview" → "Preview of the
  attachment".
- web: useSemanticElements — `<p role="status">` → `<output>` on the
  parent home page.
- web: 4 a11y/correctness auto-fixes from `lint:fix` (autoFocus
  removals, useEffect deps tightening, optional-chain in oauth route).

biome.json:
- Disabled `noSvgWithoutTitle` (decorative chevrons + arrows in
  EmberChrome) and `noImgElement` (object-URL preview in the
  question-write page is intentionally a raw <img>; next/image can't
  serve blob: URLs).
- Demoted `noRedundantAlt` to warn so the bar is "fail" → "encourage."

Local CI parity:
  bun run --filter '*' type-check  → both clean
  bun run --filter '*' lint        → 0 errors, 7 warnings
  bun --cwd server test (DB only)  → 38 pass, 30 skip
  INTEGRATION=1 bun --cwd server test → 68 pass, 0 fail
  bun run --filter web test        → 30 pass
Both recipient*.integration.test.ts files set process.env.TEST_USER_EMAIL
in beforeAll so authContext.maybeTestUser() short-circuits the real
session lookup. beforeAll runs even when its `dscribe` is `describe.skip`
— so on CI (where INTEGRATION isn't set and the integration suites are
skipped), the env var still got planted, then leaked into every other
test file's authContext-derived request. messages.test.ts saw a logged-in
test user where it expected an unauth 401, returning 200/404 instead.

Locally this didn't reproduce because file order put messages.test.ts
before the recipient suites. CI's Linux file order put it after.

Fix: gate both beforeAll bodies on SHOULD_RUN. When integration is off,
the env stays clean and messages.test.ts can do its unauth assertions.
`sendInvitation` was throwing on missing key, which the gifts route
caught and logged at error level — every dev/CI send produced an
"[email] invitation failed" error line even though the gift itself
succeeded and the recipient row + access token were already persisted.

Now `sendInvitation` returns a discriminated `InvitationOutcome`:
- `{sent:false, reason:"no-api-key"}` — dev/CI default; skip silently.
- `{sent:true}` — Resend accepted.
- throws — Resend returned non-2xx (real upstream failure).

The route logs the no-key case at info level and only treats actual
exceptions as errors, so error monitoring stays signal.

Tests: 3 new — no-key path skips fetch entirely, 200 path returns
{sent:true} with correct Authorization header, non-2xx still throws.
All 71 server tests pass.
Adds a new `letter` step to the giver onboarding flow, between
`delivery` and `send`, where the giver writes the personal note
their recipient sees first on /r/:token/letter.

Schema (migration 005):
- `ALTER TYPE onboarding_step ADD VALUE 'letter' BEFORE 'send'`.
- `gifts.personal_message` already exists from migration 004 — the
  PATCH endpoint already accepts it; sync now propagates it.

Web:
- New /onboarding/letter page modeled after /onboarding/recipient:
  textarea (4000 char cap) + Whisper voice button. Optional — Skip
  link routes straight to send and falls back to the generic letter
  on the parent side. Placeholder pre-fills with the canonical
  "I made you something. Open whenever you have a quiet minute."
- delivery/page.tsx now routes to /onboarding/letter (was /send).
- _lib/sync.ts adds `personalMessage` to patchGiftFields so the next
  sync round-trip persists the letter to gifts.personal_message.
- ONBOARDING_STEPS gains `letter` before `send` (matches server enum).

Side fix:
- server/src/lib/email.test.ts had typeof-fetch TS errors that surfaced
  on the latest type-check (cast through unknown, replace `null` capture
  state with object init for proper inference). 71/71 server tests still
  pass.

Browser smoke verified: started at /onboarding/delivery with intent=mom
in localStorage → click Continue → lands on /onboarding/letter with
"Write a note to Mom." headline + "Mom — I made you something..."
placeholder → typing then Continue → lands on /onboarding/send with
the personalMessage in local state ready for the next sync.
@slopless-scanner

slopless-scanner Bot commented May 3, 2026

Copy link
Copy Markdown

⚠️ Slopless Review

Confidence: 🟡 4/5 · Verdict: REQUEST_CHANGES · Risk: HIGH · Findings: 8

Reviewed the PR diff without prior scan context (no architecture artifacts found). Produced 8 finding(s) across 3 vulnerability class(es) (best_practice, code_quality, security) in 24.0s.

PR review complete: 8 findings, verdict: request_changes

Scope & coverage
  • Lines: 298
  • Checks performed: best_practice · code_quality · security

Findings

🛑 CRITICAL — Missing Authorization Check on Personal Message Endpoint

web/app/onboarding/_lib/sync.ts:100-101 · confidence: high · CWE-639

The PR adds logic to patch personalMessage via patchGiftFields() in sync.ts (line 100-101), but there is no evidence of authorization verification that the current user owns the gift being modified. This is a classic BOLA (Broken Object Level Authorization) vulnerability. An attacker could modify another user's personal message by knowing their gift ID.

Suggested fix: Verify that the backend endpoint /api/gifts/{id} PATCH handler checks that current_user.id === gift.giver_id before allowing the personalMessage field to be updated. The check must happen server-side, not client-side.

Code context
if (typeof data.personalMessage === "string")
		patch.personalMessage = data.personalMessage

🔴 HIGH — Unvalidated Audio Transcription Endpoint

web/app/onboarding/letter/page.tsx:88-92 · confidence: high · CWE-434

The /api/transcribe endpoint (called at line 88 in letter/page.tsx) accepts audio file uploads with no visible input validation, file size limits, or rate limiting shown in the PR. This could enable: (1) DoS attacks via large file uploads, (2) abuse of third-party transcription API quota, (3) processing of malicious audio files.

Suggested fix: Implement server-side validation on the /api/transcribe endpoint: (1) Enforce maximum file size (e.g., 25MB), (2) Validate MIME type is audio, (3) Implement rate limiting per user/IP, (4) Add authentication check to ensure only logged-in users can transcribe, (5) Consider adding virus scanning for uploaded files.

Code context
const fd = new FormData()
fd.append("audio", blob, `audio.${ext}`)
const res = await fetch(`/api/transcribe`, { method: "POST", body: fd })
const data = (await res.json()) as { text?: string; error?: string }

🔴 HIGH — Missing Authentication on Transcription Endpoint

web/app/onboarding/letter/page.tsx:88-92 · confidence: high · CWE-306

The transcription endpoint at /api/transcribe is called without any visible authentication token or session validation. This endpoint should require the user to be authenticated to prevent unauthorized use and quota abuse.

Suggested fix: Ensure the backend /api/transcribe endpoint: (1) Requires authentication (check session/JWT), (2) Validates the user is in an active onboarding flow, (3) Associates transcription quota with the authenticated user.

Code context
const res = await fetch(`/api/transcribe`, { method: "POST", body: fd })

🟠 MEDIUM — Unhandled Promise Rejection in Voice Recording

web/app/onboarding/letter/page.tsx:88-92 · confidence: medium

In the transcribe() function (line 82-102), if res.json() throws an error, it will cause an unhandled promise rejection. The error handling only checks res.ok after parsing, but parsing itself could fail.

Suggested fix: Wrap the JSON parsing in a try-catch block or check response headers before parsing: const data = res.ok ? await res.json() : { error: 'Server error' }

Code context
const res = await fetch(`/api/transcribe`, { method: "POST", body: fd })
const data = (await res.json()) as { text?: string; error?: string }
if (!res.ok) {

🟠 MEDIUM — Incomplete PR Description - Missing Backend Implementation

web/app/onboarding/delivery/page.tsx:1 · confidence: high

The PR description mentions changes to delivery/page.tsx but the diff is truncated. More critically, the PR does not show the backend implementation of the /api/transcribe endpoint or the PATCH endpoint for updating personalMessage. These are critical for security review.

Suggested fix: Provide complete diffs for all modified files, especially backend API endpoints. Ensure the PR includes the implementation of /api/transcribe and the gift PATCH endpoint with full authorization checks.

Code context
N/A - File diff truncated

🟠 MEDIUM — No CSRF Protection Visible on Form Submission

web/app/onboarding/letter/page.tsx:130-135 · confidence: medium · CWE-352

The letter form submission (line 130-135 in the truncated code) does not show CSRF token handling. While Next.js may handle this automatically, it should be explicitly verified.

Suggested fix: Verify that the backend PATCH endpoint for updating personalMessage validates CSRF tokens or uses SameSite cookie attributes. Ensure the API client includes CSRF tokens in requests.

Code context
N/A - Code truncated

🟡 LOW — Hardcoded Default Message Could Be Externalized

web/app/onboarding/letter/page.tsx:1-246 · confidence: low

The placeholder message 'I made you something. Open whenever you have a quiet minute.' is hardcoded in the component. This should be configurable or externalized for i18n support.

Suggested fix: Move the default message to a constants file or i18n configuration for easier maintenance and localization.

Code context
Placeholder pre-fills with the canonical _"I made you something. Open whenever you have a quiet minute."_

🟠 MEDIUM — Potential XSS via Transcribed Text

web/app/onboarding/letter/page.tsx:95-102 · confidence: medium · CWE-79

The transcribed text from the audio is inserted into the letter state and eventually saved to the database. If the transcription service returns unsanitized content or if there's a backend vulnerability, this could lead to XSS when the letter is displayed to the recipient.

Suggested fix: Ensure that: (1) The transcription API response is validated and sanitized, (2) The backend sanitizes personalMessage before storing, (3) The recipient-facing letter display uses proper HTML escaping or React's built-in XSS protection.

Code context
const text = (data.text || "")
// ... text is appended to letter state

Reviewed by Slopless · Install on your repo · comment @slopless to re-run

@xBalbinus

Copy link
Copy Markdown
Author

@valet-test-env please review

@valet-test-env valet-test-env Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid PR — the intent matches the diff, conventions are consistent throughout, and all CI checks pass.

One real issue worth fixing before merge:

  • web/app/onboarding/letter/page.tsx (mic leak on navigate-away): If the user starts recording and then hits Back/Skip/Continue without stopping, recorderRef.current is left running and the mic stream tracks are never stopped. Add a useEffect cleanup that calls stopRecording() on unmount.

Created on behalf of xiangan@turnkey.io xiangan@turnkey.io

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants