diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2bf5e56..08923e7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,8 @@ jobs: uses: oven-sh/setup-bun@v2 - name: Install dependencies run: bun install --frozen-lockfile + - name: Apply DB migrations + run: bun run --filter server db:migrate - name: Test run: bun run --filter server test diff --git a/.gitignore b/.gitignore index 286209c..5d01618 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,7 @@ yarn-error.log* next-env.d.ts # elysia -server/dist \ No newline at end of file +server/dist + +# claude code worktree + runtime artifacts +.claude/ diff --git a/BACKEND_HANDOFF_V2.md b/BACKEND_HANDOFF_V2.md new file mode 100644 index 0000000..fba1129 --- /dev/null +++ b/BACKEND_HANDOFF_V2.md @@ -0,0 +1,723 @@ +# Ember — Backend Handoff v2 + +Comprehensive, page-by-page backend spec covering everything the frontend currently collects, every route, every API call, and every state field. Supersedes [BACKEND_HANDOFF.md](BACKEND_HANDOFF.md) (which only covered child onboarding). + +> Read sections 1–3 once. Treat sections 4–6 as reference — open the section that matches the page/endpoint you're working on. + +--- + +## 1. Product context (mandatory read) + +Ember is a **gift-based memory product**. The buyer/initiator is the adult child; the recipient is a parent or loved one. Two distinct flows: + +- **Child (giver) flow** — onboard, curate questions, send a gift to a recipient. +- **Parent (recipient) flow** — receive an email with a tokenized link, journal at their own pace (text, voice, photo, AI chat), eventually share the journal back. + +**Core ritual:** Child gives → parent fills → parent shares → child receives the archive. + +**Brand non-negotiables that affect backend:** +- **No AI avatars. No simulation of the person. Ever.** The summarize/converse endpoints must never speak as the recipient or the giver. +- Privacy-first by default. The parent's journal is private to them until they explicitly share. After share, the journal is **archived (read-only)**. +- Voice is the soul of the input experience — **keep raw audio**, not just transcripts. +- Slowness is the feature. **No engagement loops or push notifications** nudging the parent. + +--- + +## 2. What exists today + +### 2.1 Frontend (Next.js 16, app router, Turbopack) + +Routes under `web/app/`: + +| Tier | Route | Source | +|---|---|---| +| Shared | `/login` | `web/app/login/page.tsx` | +| Child | `/onboarding/*` | `web/app/onboarding/**/page.tsx` | +| Child | `/dashboard` | `web/app/dashboard/page.tsx` | +| Parent | `/r/[token]/*` | `web/app/r/[token]/**/page.tsx` | +| Glue | `/oauth` | `web/app/oauth/route.ts` (XORS callback decrypt) | + +State currently lives in `localStorage`: +- `ember:onboarding:v1` — child's gift-in-progress +- `ember:parent:{token}:v1` — recipient's journal state, scoped per token +- `ember:parent:recent-token` — last token this browser saw (used by `/login` to recover the parent's journal) + +### 2.2 Backend (Elysia + Bun) + +Already on `main`: +- `GET /auth/me`, `POST /auth/logout` — XORS-backed identity ([server/src/routes/auth.ts](server/src/routes/auth.ts), [server/src/lib/xors-identity.ts](server/src/lib/xors-identity.ts)). **In-memory user store** — must move to a real DB. +- `POST /messages/*` — example CRUD shape (in-memory). +- Eden client wired in [web/app/lib/api.ts](web/app/lib/api.ts) with `treaty`. + +Added in this PR: +- `POST /transcribe` — multipart `audio` → OpenAI Whisper → `{ text }` ([server/src/routes/transcribe.ts](server/src/routes/transcribe.ts)). Needs `OPENAI_API_KEY`. +- `POST /ai/converse` — Claude Opus 4.7 chat. Three topics with different system prompts: `about`, `why`, `parent-reflect` ([server/src/routes/ai.ts](server/src/routes/ai.ts)). Needs `ANTHROPIC_API_KEY`. +- `POST /ai/summarize` — distills a chat into prose appropriate to the topic. + +### 2.3 Stubs to replace + +- **All persistence** — every page writes to `localStorage`. See §7 for migration plan. +- **Question library (child)** — hardcoded in [web/app/onboarding/questions/_lib/library.ts](web/app/onboarding/questions/_lib/library.ts) (26 questions, 5 categories, 8 marked `suggested`). +- **Prompt library (parent)** — hardcoded in [web/app/r/[token]/_lib/prompts.ts](web/app/r/[token]/_lib/prompts.ts) (12 prompts). +- **Photos** — child custom-question photos and parent photo entries are inline data URLs (4MB cap on the child side, 4MB cap on the parent side). Needs S3 + multipart upload. +- **Audio** — voice recordings on the parent side currently transcribe-and-discard. The voice page persists only the transcript and a `durationSeconds` value. **Backend must keep raw audio.** +- **Send (child)** — `POST /onboarding/send` is a no-op locally; stamps `sentAt` in localStorage and shows the ack screen. No email is sent, no recipient row is created. +- **Recipient lookup by token** — currently mocked. The token in the URL is purely cosmetic; mock data is hardcoded for `Sofia` / `Mom`. +- **Login (parent)** — `/login` recipient form doesn't validate against any backend; it just routes to `/r/{recent-token}/journal`. + +--- + +## 3. Architecture diagram + +``` +┌──────────────────┐ ┌──────────────────┐ +│ CHILD (giver) │ ─── XORS Google OAuth ────────▶ │ XORS identity │ +│ │ │ (api.xors.xyz) │ +│ /login │ ◀── xors_session cookie ──────── │ │ +│ /onboarding/* │ └──────────────────┘ +│ /dashboard │ │ +└────────┬─────────┘ │ + │ │ + │ POST /gifts/:id/send │ + ▼ │ +┌──────────────────┐ email invitation ┌──────────────────┐ │ +│ API SERVER │ ─────────────────▶ │ PARENT inbox │ │ +│ (Elysia/Bun) │ │ │ │ +│ │ │ link contains │ │ +│ /transcribe │ ◀──────────────── │ access token │ │ +│ /ai/* │ └────────┬─────────┘ │ +│ /gifts/* │ │ │ +│ /r/:token/* │ ▼ │ +│ /auth/* │ ┌──────────────────┐ │ +└────────┬─────────┘ │ PARENT (web) │ │ + │ │ │ │ + │ writes/reads │ /r/[token]/* │ ◀──┘ same auth + ▼ │ /login (parent) │ optional +┌──────────────────┐ └──────────────────┘ +│ PERSISTENCE │ +│ - Postgres │ +│ - S3 (audio + │ +│ photos) │ +│ - email │ +│ provider │ +└──────────────────┘ +``` + +--- + +## 4. Page-by-page breakdown + +For every page: **route**, **purpose**, **what frontend reads/writes**, **API endpoints needed**, **edge cases**. Pages are listed in user-flow order, then alphabetically by section. + +### 4A. Shared + +#### `/login` +- **Purpose:** unified login that routes giver vs recipient. +- **Frontend:** `useUser()` (giver session), localStorage check for `ember:onboarding:v1` to auto-redirect signed-in givers (`→ /dashboard` if `sentAt`, else `→ /onboarding/account`). Recipient form posts email + password. +- **Endpoints needed:** + - `GET /auth/me` (already exists) — for giver session check. + - `POST /auth/recipient/login` — body `{ email, password }`, returns `{ token, redirectTo }`. Sets a recipient session cookie or returns a token to drop in the URL. **Not built.** + - Giver path triggers XORS OAuth (already wired). +- **Edge cases:** if a giver is signed in but the user picks "I received a gift", we should still let them in (don't auto-redirect). + +--- + +### 4B. Child (giver) flow + +State key: `ember:onboarding:v1`. Steps tracked in `currentStep` field; pages always update it on entry so resume-from-where-you-left-off works. + +#### `/onboarding` → step `welcome` +- **Purpose:** warm welcome card. +- **Collects:** nothing. +- **Endpoint:** `POST /gifts` to create a draft and store the gift ID. (Currently localStorage-only.) +- **Edge:** if a draft already exists, just continue. + +#### `/onboarding/intent` → step `intent` +- **Purpose:** "Who are you here for?" +- **Collects:** `intent: "mom" | "dad" | "loved-one" | "undecided"`. +- **Endpoint:** `PATCH /gifts/:id { intent }`. + +#### `/onboarding/account` → step `account` +- **Purpose:** Google sign-in via XORS. Persists user email to gift state. After sign-in, if `sentAt` is set, redirects to `/dashboard`. +- **Collects:** `email` (from `useUser()`). +- **Endpoint:** existing `GET /auth/me`. After successful sign-in, `PATCH /gifts/:id { ownerUserId }` if not already linked. + +#### `/onboarding/recipient` → step `recipient` +- **Purpose:** "Tell us a little about them." 500-char textarea + voice input. +- **Collects:** `about: string` (≤ 500 chars). +- **Endpoints:** + - `POST /transcribe` (multipart audio) — already exists. + - `PATCH /gifts/:id { about }`. +- **Edge:** voice transcript is appended (with a space) to existing text; capped at 500. + +#### `/onboarding/recipient/ai` → step stays `recipient`, intermediate +- **Purpose:** "Let's talk about her/him/them." Multi-turn AI chat that distills into the `about` field. +- **Collects:** `about` (overwritten/merged with the AI summary). +- **Endpoints:** + - `POST /ai/converse` with `{ topic: "about", messages, intent, hint: priorAbout }` — already exists. + - `POST /ai/summarize` with `{ topic: "about", messages }` — already exists. + - `PATCH /gifts/:id { about }`. +- **Edge:** AI initiates with the first question on mount (empty `messages` array). Voice button on chat input (Whisper). On "End conversation", merges summary with prior `about`, capped at 2000. + +#### `/onboarding/why` → step `why` +- **Purpose:** "Why do you want to make this for them?" 800-char textarea + voice. +- **Collects:** `why: string` (≤ 800 chars). +- **Endpoints:** same as `/onboarding/recipient` but for `why`. + +#### `/onboarding/why/ai` → step stays `why`, intermediate +- **Purpose:** "Why this gift, why now?" AI chat. Distills into `why`. +- **Collects:** `why` (merged with AI summary). +- **Endpoints:** `POST /ai/converse` and `POST /ai/summarize` with `topic: "why"`. + +#### `/onboarding/world` → step `world` +- **Purpose:** "Who's in her/his/their world?" Add/edit/remove people. +- **Collects:** `people: Person[]` where `Person = { id, name, relationship, age?, description }`. +- **Endpoints:** + - `POST /gifts/:id/people` — single create. + - `PATCH /gifts/:id/people/:pid` — update. + - `DELETE /gifts/:id/people/:pid`. + - `PUT /gifts/:id/people` (full replace) — easier for the frontend's "save the array" pattern. + - `POST /transcribe` for the description voice button. +- **Edge:** description voice transcript appended (240 char cap on description). + +#### `/onboarding/questions` → step `questions` +- **Purpose:** browse + select questions. Search, category filters (Suggested / Childhood / Love / Wisdom / Everyday / Their story), tap-to-toggle. Footer counter "N selected · 3 minimum" + Review. +- **Collects:** `questions.selectedIds: string[]`. +- **Endpoints:** + - `GET /question-templates?category=&q=` — list/search the curated catalog. Catalog lives in DB; seed from [web/app/onboarding/questions/_lib/library.ts](web/app/onboarding/questions/_lib/library.ts). + - `POST /gifts/:id/questions` — add a library question by `templateId`. + - `DELETE /gifts/:id/questions/:qid` — remove. + - **(Optional)** `POST /gifts/:id/suggest-questions` — AI-personalized suggestions based on `about` + `why` + `people`. Sketched in §5. +- **Edge:** "Suggested" tab shows hand-curated `suggested: true` templates plus all custom questions. Search filters all questions globally. + +#### `/onboarding/questions/write` → step stays `questions`, intermediate +- **Purpose:** custom question. Text (280 char) + optional photo (4MB cap) + optional preface. +- **Collects:** appends to `questions.custom: CustomQuestion[]` and auto-selects the new ID. +- **Endpoints:** + - `POST /gifts/:id/questions` with `{ source: "custom", text, preface? }` returns `{ id }`. + - `POST /gifts/:id/questions/:qid/photo` (multipart `photo`) — uploads to S3, returns `{ photoUrl }`. + +#### `/onboarding/questions/review` → step stays `questions`, intermediate +- **Purpose:** review selected, inline edit text, remove, add more. +- **Collects:** + - `questions.edits: Record` — overrides for library question text (preserves the original template). + - `questions.custom[i].text` — direct edit for custom. + - `questions.selectedIds` — removal pulls IDs out. +- **Endpoints:** + - `PATCH /gifts/:id/questions/:qid { text }` — works for both library and custom; backend stores the override on the per-gift `Question` row regardless of source. + - `DELETE /gifts/:id/questions/:qid`. + +#### `/onboarding/delivery` → step `delivery` +- **Purpose:** "How would you like to give this gift?" Three options. Email default; Physical letter shows "Coming soon"; In person selectable. +- **Collects:** `delivery: "email" | "in-person"` (`"physical"` enum reserved but disabled). +- **Endpoint:** `PATCH /gifts/:id { delivery }`. + +#### `/onboarding/send` → step `send` +- **Purpose:** "Ready to send." Email-style preview + summary rows (For / Delivery / Questions). Inline edit recipient name + email. Confirmation modal. +- **Collects:** `recipientName`, `recipientEmail` (required when delivery = email). +- **Endpoints:** + - `PATCH /gifts/:id { recipientName, recipientEmail }`. + - `POST /gifts/:id/send` — validates (≥1 question selected, recipientEmail valid if delivery = email), creates `Recipient` row + access token, queues invitation email, returns `{ sentAt, recipient: { accessToken } }`. **Not built.** +- **Idempotency:** the send endpoint must be idempotent on `sentAt`. If already sent, return current state. + +#### `/onboarding/sent` → step `complete` +- **Purpose:** "It's on its way." Acknowledgement. "Go to dashboard" link. +- **Endpoints:** none. + +#### `/dashboard` +- **Purpose:** post-send dashboard. "For {recipientName}." with sent date + delivery + status + question count + "+ Add a question" + Settings/Help links. +- **Collects:** displays `sentAt`, `delivery`, `recipientName`, `questions.selectedIds.length`. +- **Endpoints:** `GET /gifts/:id` to load full state. +- **Edge:** redirects to `/onboarding` if no `sentAt` is in state. + +--- + +### 4C. Parent (recipient) flow + +State key: `ember:parent:{token}:v1`. Steps: `welcome → letter → how-it-works → account → home`. + +The recipient never authenticates against XORS. The token is the auth surface; an optional email/password account is collected on the `account` step so they can return on any device. + +#### `/r/[token]` → step `welcome` +- **Purpose:** "Take your time." Cream eyebrow `A GIFT FROM {giverName}` + message. +- **Reads from backend:** `giverName`, `giverPronoun`, recipient's name (for the avatar initial later). +- **Endpoint:** `GET /r/:token` — returns the gift envelope (giver name, recipient name, personal letter, prompts, share state, account flag). +- **Edge:** if `step !== "welcome"` (returning visit), `router.replace()`s to wherever they left off. If `accountCreated` and journal exists, lands on `/r/[token]/journal` (the future direction of this redirect — see §10 open questions). + +#### `/r/[token]/letter` → step `letter` +- **Purpose:** the personal letter from the giver. Serif typography, paginated footer. +- **Reads:** `personalMessage` (string, optional). If null, frontend renders a generic warm letter. +- **Endpoint:** `GET /r/:token` — already covers this. Backend should optionally provide `personalMessage` if the giver wrote one (currently no UI for the giver to author one — see §10 open questions). + +#### `/r/[token]/start` → step `how-it-works` +- **Purpose:** "Three things to know." Static explainer. +- **Endpoints:** none. + +#### `/r/[token]/account` → step `account` +- **Purpose:** "Save your space." Email (pre-filled from invitation) + password. +- **Collects:** `email`, `accountCreated: true`. +- **Endpoint:** `POST /r/:token/account { email, password }` — creates a recipient login. Backend hashes the password server-side; frontend never persists it. **Not built.** +- **Edge:** the email field shows "Pre-filled from the invitation" — backend must seed it from the recipient row that was created at gift-send time. + +#### `/r/[token]/home` → step `home` (Today tab) +- **Purpose:** "What do you feel like writing today?" with the **EntryComposer** + three starting points (Pick a prompt / Talk it through / Record voice). Avatar menu (sign out) in top right. +- **Collects:** appends to `entries: JournalEntry[]` via the composer. +- **Endpoint:** `POST /r/:token/entries` — see §5.5. +- **Archived view:** if `sharing.sharedAt` is set, the headline becomes "Your journal is shared." and the composer + starting points are replaced by an archived card linking to the journal. + +#### `/r/[token]/journal` (Journal tab) +- **Purpose:** three sub-views: List (grouped by month), Calendar (interactive — tap day to filter), Media (photo + audio entries). FAB "+ New entry". +- **Reads:** all `entries`. +- **Endpoint:** `GET /r/:token/entries` — list, sorted desc by `createdAt`. +- **Archived view:** banner at top showing "Archived · Shared on {date}". FAB hidden. + +#### `/r/[token]/journal/new` +- **Purpose:** dedicated composer screen (FAB target + prompt-tap target). +- **Optional query param:** `?promptId=p1` — pre-fills the prompt context. +- **Collects:** new `JournalEntry` (text + optional photo + optional `promptId`/`promptText`). +- **Endpoint:** `POST /r/:token/entries`. +- **Archived:** redirects to `/r/[token]/journal`. + +#### `/r/[token]/prompts` (Prompts tab) +- **Purpose:** prompt list with two filters (All / Used). Used prompts greyed out at the bottom. Tapping an unused prompt → `/r/[token]/journal/new?promptId=…`. +- **Reads:** prompt library + entries (to determine "used"). +- **Endpoints:** + - `GET /r/:token/prompts` — returns the prompts the giver curated for this gift (from the giver's `Question` rows on the gift). + - "Used" derived client-side from `entries` where `promptId` matches. +- **Archived:** all prompts disabled, banner at top. + +#### `/r/[token]/ai` +- **Purpose:** chat with Ember (parent-reflect tone). "Save as journal entry" distills the conversation into a journal entry. +- **Collects:** appends to `entries` with `source: "ai"` and `text` = the summary. +- **Endpoints:** + - `POST /ai/converse` `{ topic: "parent-reflect", messages, intent: "n/a" }` — already exists. + - `POST /ai/summarize` `{ topic: "parent-reflect", messages }` — already exists. + - `POST /r/:token/entries` `{ source: "ai", text }`. +- **Archived:** "Save" button disabled. + +#### `/r/[token]/voice` +- **Purpose:** dedicated voice note screen. Big record button → live timer → Whisper → transcript review (editable) → save. +- **Collects:** `JournalEntry` with `source: "voice"`, `text` = transcript, `durationSeconds`. +- **Endpoints:** + - `POST /transcribe` (already exists). + - `POST /r/:token/entries` `{ source: "voice", text, audioBlobId, durationSeconds }`. **Backend must accept and store the raw audio file** — see §6. +- **Archived:** redirects to `/r/[token]/journal`. + +#### `/r/[token]/share` (Share tab) +- **Purpose:** sharing settings + "Share what I've written so far" button. Sharing is **one-time → archives**. +- **Reads/Writes:** `sharing: SharingState`. +- **Endpoints:** + - `GET /r/:token/sharing` (could be folded into `GET /r/:token`). + - `POST /r/:token/share` — performs the actual share. Sets `sharedAt`, snapshots `entries`, queues an email notification to the giver, locks the recipient's journal as archived. **Not built.** +- **Archived view:** "Currently set to" + change/share buttons replaced by a single "Archived · Shared with {giver} on {date} · {N} entries sent." card. + +#### `/r/[token]/share/when` +- **Purpose:** "How and when to share." Picker with 4 modes: `when-ready`, `legacy`, `date`, `milestone`. Date and milestone require a specific calendar date (milestone "In one year" auto-fills). +- **Collects:** `sharing.mode`, `sharing.date?`, `sharing.milestonePreset?`, `sharing.milestoneText?`. +- **Endpoint:** `PATCH /r/:token/sharing { mode, date?, milestonePreset?, milestoneText? }`. + +#### `/r/[token]/share/done` +- **Purpose:** "It's on its way. {Subject} has it." Success ack. +- **Endpoints:** none. + +--- + +## 5. API endpoint reference + +All endpoints require auth except `/r/:token/*` (token-authenticated) and the public OAuth flow. + +### 5.1 Auth + +Already exists. For completeness: + +| Method | Path | Body | Returns | Notes | +|---|---|---|---|---| +| GET | `/auth/me` | — | `{ user }` or 401 | Reads `xors_session` cookie. | +| POST | `/auth/logout` | — | `{ ok: true }` | Clears cookie. | + +To add for the unified `/login` and parent account: + +| Method | Path | Body | Returns | Notes | +|---|---|---|---|---| +| POST | `/auth/recipient/login` | `{ email, password }` | `{ token, redirectTo }` | Looks up by email, sets recipient session cookie. | +| POST | `/r/:token/account` | `{ email, password }` | `{ ok: true }` | Creates the recipient login at the account-creation step. Hash password server-side. | +| POST | `/auth/recipient/logout` | — | `{ ok: true }` | Optional — cookie clear. | + +### 5.2 Gifts (giver-side) + +All require giver auth. Scope every query by `userId`. + +| Method | Path | Body | Returns | Notes | +|---|---|---|---|---| +| POST | `/gifts` | `{ intent? }` | `Gift` | Creates draft. | +| GET | `/gifts` | — | `Gift[]` | List user's gifts. | +| GET | `/gifts/:id` | — | `Gift` (with people, questions) | Full read. | +| PATCH | `/gifts/:id` | partial Gift fields | `Gift` | Update intent/about/why/delivery/recipient/currentStep/timeLock. | +| DELETE | `/gifts/:id` | — | 204 | Hard delete if `status="draft"`, soft archive otherwise. | +| POST | `/gifts/:id/send` | — | `{ sentAt, recipient: { accessToken } }` | Idempotent on `sentAt`. Validates: ≥1 question selected, recipientEmail valid if delivery=email. Creates `Recipient` row, queues invitation email. | + +### 5.3 People (per-gift) + +| Method | Path | Body | Returns | +|---|---|---|---| +| GET | `/gifts/:id/people` | — | `Person[]` | +| POST | `/gifts/:id/people` | `{ name, relationship, age?, description }` | `Person` | +| PATCH | `/gifts/:id/people/:pid` | partial fields | `Person` | +| DELETE | `/gifts/:id/people/:pid` | — | 204 | +| PUT | `/gifts/:id/people` | `Person[]` | `Person[]` (full replace, easier for frontend bulk-save) | + +### 5.4 Questions (per-gift) + +| Method | Path | Body | Returns | +|---|---|---|---| +| GET | `/gifts/:id/questions` | — | `Question[]` ordered by `position` | +| POST | `/gifts/:id/questions` | `{ source: "library", templateId } \| { source: "custom", text, preface? }` | `Question` | +| PATCH | `/gifts/:id/questions/:qid` | `{ text?, preface?, position? }` | `Question` | +| DELETE | `/gifts/:id/questions/:qid` | — | 204 | +| POST | `/gifts/:id/questions/:qid/photo` | multipart `photo` | `{ photoUrl }` | +| DELETE | `/gifts/:id/questions/:qid/photo` | — | 204 | +| GET | `/question-templates?category=&q=` | — | `QuestionTemplate[]` (catalog) | + +### 5.5 Recipient (parent-side, token-authenticated) + +| Method | Path | Body | Returns | +|---|---|---|---| +| GET | `/r/:token` | — | `{ giver: {name, pronoun}, recipient: {name, email}, personalMessage?, prompts: ParentPrompt[], sharing: SharingState, accountCreated }` | +| GET | `/r/:token/entries` | — | `JournalEntry[]` (most recent first) | +| GET | `/r/:token/entries/:eid` | — | `JournalEntry` (full) | +| POST | `/r/:token/entries` | `{ source, text?, promptId?, preface? }` | `JournalEntry` | +| PATCH | `/r/:token/entries/:eid` | partial fields | `JournalEntry` | +| DELETE | `/r/:token/entries/:eid` | — | 204 | +| POST | `/r/:token/entries/:eid/photo` | multipart `photo` | `{ photoUrl }` | +| POST | `/r/:token/entries/:eid/audio` | multipart `audio` | `{ audioUrl, durationSeconds }` | +| GET | `/r/:token/prompts` | — | `ParentPrompt[]` (the questions the giver curated, exposed as recipient prompts) | +| GET | `/r/:token/sharing` | — | `SharingState` (or include in `GET /r/:token`) | +| PATCH | `/r/:token/sharing` | `{ mode, date?, milestonePreset?, milestoneText? }` | `SharingState` | +| POST | `/r/:token/share` | — | `SharingState` (with `sharedAt` set). One-time; subsequent calls return current state. Snapshots `entries.length` and notifies giver via email. | + +### 5.6 AI (already built) + +| Method | Path | Body | Returns | Notes | +|---|---|---|---|---| +| POST | `/transcribe` | multipart `audio` | `{ text }` | OpenAI Whisper. ≤25 MB. | +| POST | `/ai/converse` | `{ topic: "about" \| "why" \| "parent-reflect", messages, intent, hint? }` | `{ message }` | Claude Opus 4.7. Three system prompts in [server/src/routes/ai.ts](server/src/routes/ai.ts). | +| POST | `/ai/summarize` | `{ topic, messages }` | `{ summary }` | Distills conversation into prose appropriate to the topic. | + +To add (optional, mentioned in v1 spec): + +| POST | `/gifts/:id/suggest-questions` | — | `{ suggestions: { id, text }[] }` | Reads gift's `intent` + `about` + `why` + `people`, calls Claude with the curated library as context, returns 6–10 personalized template-shaped suggestions. | + +--- + +## 6. Data model + +``` +User (1) ──< Gift (N) +Gift (1) ──< Question (N) +Gift (1) ──< Person (N) +Gift (1) ──< Recipient (1) // single recipient per gift in v1 +Recipient (1) ──< JournalEntry (N) +Recipient (1) ──< Sharing (1) +QuestionTemplate (catalog, workspace-wide; not per-user) +``` + +### 6.1 `User` + +| field | type | notes | +|---|---|---| +| id | uuid / `usr_...` | PK | +| xorsUserId | string | from XORS viewer; unique | +| email | string | from XORS | +| displayName | string \| null | from XORS | +| pronoun | jsonb \| null | `{ subject, possessive, object }` — **not yet collected during onboarding**, see §10. Used by parent UI for "Read what {she} wrote." | +| createdAt | timestamptz | | + +### 6.2 `Gift` + +| field | type | notes | +|---|---|---| +| id | uuid / `gft_...` | PK | +| userId | uuid | FK → User (the giver) | +| intent | enum | `mom` \| `dad` \| `loved-one` \| `undecided` | +| about | text | freeform; AI summary may be merged in | +| why | text | freeform; AI summary may be merged in | +| delivery | enum | `email` \| `in-person` (`physical` reserved, disabled in UI) | +| recipientName | string | defaults from intent ("Mom"/"Dad"/"Them") | +| recipientEmail | string \| null | required when delivery=email at send time | +| personalMessage | text \| null | giver-authored letter to the parent — **no UI yet** to collect, parent renders a generic letter when null | +| currentStep | enum | mirrors `OnboardingStep` for resume | +| status | enum | `draft` \| `sent` \| `archived` | +| sentAt | timestamptz \| null | | +| createdAt | timestamptz | | +| updatedAt | timestamptz | | + +### 6.3 `Person` (people in recipient's world) + +| field | type | notes | +|---|---|---| +| id | uuid | PK | +| giftId | uuid | FK | +| name | string | | +| relationship | string | free text | +| age | string | free text ("6", "early 30s", etc.) | +| description | text | ≤ 240 chars | +| position | int | display order | + +### 6.4 `QuestionTemplate` (catalog) + +| field | type | notes | +|---|---|---| +| id | string | e.g. `tpl_ch1`; stable for AI re-suggestion | +| text | text | | +| categories | string[] | `childhood` / `love` / `wisdom` / `everyday` / `their-story` | +| suggested | bool | hand-curated default suggestion flag | +| createdAt | timestamptz | | + +Seed from [web/app/onboarding/questions/_lib/library.ts](web/app/onboarding/questions/_lib/library.ts). + +### 6.5 `Question` (per-gift selection) + +| field | type | notes | +|---|---|---| +| id | uuid | PK | +| giftId | uuid | FK | +| source | enum | `library` \| `custom` | +| templateId | string \| null | FK → QuestionTemplate when source=library | +| text | text | the actual text shown to the recipient (overrides template if edited on the review screen) | +| photoUrl | string \| null | S3 URL | +| preface | text \| null | optional intro line for photo questions | +| position | int | display order | +| createdAt | timestamptz | | + +The frontend's three-bucket shape (`selectedIds + custom + edits`) collapses to a single sorted `Question[]` per gift on the backend. + +### 6.6 `Recipient` + +| field | type | notes | +|---|---|---| +| id | uuid | PK | +| giftId | uuid | FK; **unique** (one recipient per gift in v1) | +| accessToken | string | opaque, unguessable; in the email link | +| email | string | mirror of Gift.recipientEmail at send time | +| name | string | mirror of Gift.recipientName | +| passwordHash | string \| null | populated when they create an account on `/r/:token/account` | +| accountCreatedAt | timestamptz \| null | | +| firstSeenAt | timestamptz \| null | first time they hit `/r/:token` | +| lastActiveAt | timestamptz \| null | | + +### 6.7 `JournalEntry` + +| field | type | notes | +|---|---|---| +| id | uuid | PK | +| recipientId | uuid | FK | +| giftId | uuid | FK (denormalized for query convenience) | +| source | enum | `free-write` \| `prompt` \| `ai` \| `voice` \| `photo` | +| text | text \| null | body (or transcript for voice/ai) | +| promptId | string \| null | references the giver's `Question.id` (or one of the pre-seeded prompts the giver didn't author) | +| promptText | text \| null | snapshot of the prompt at write time (in case the giver edits it later) | +| photoUrl | string \| null | S3 | +| audioUrl | string \| null | S3 — **raw audio, kept** | +| durationSeconds | int \| null | for voice | +| preface | text \| null | mirrors the giver's question preface if applicable | +| createdAt | timestamptz | | + +### 6.8 `Sharing` (one per Recipient) + +| field | type | notes | +|---|---|---| +| recipientId | uuid | PK | +| mode | enum | `when-ready` \| `legacy` \| `date` \| `milestone` | +| date | date \| null | required when `mode=date` or `mode=milestone` | +| milestonePreset | enum \| null | `future-birthday` \| `anniversary` \| `in-one-year` \| `custom` | +| milestoneText | text \| null | for `milestonePreset=custom` | +| sharedAt | timestamptz \| null | when share was performed; immutable once set | +| lastSharedSnapshotCount | int \| null | entries.length at the moment of share | + +**Sharing is one-time in v1.** Once `sharedAt` is set, the recipient's journal becomes read-only and the giver receives an archive notification. + +### 6.9 Enum reference + +```ts +type OnboardingStep = + | "welcome" | "intent" | "account" | "recipient" + | "why" | "world" | "questions" | "delivery" + | "send" | "complete" + +type ParentStep = + | "welcome" | "letter" | "how-it-works" | "account" | "home" + +type SharingMode = "when-ready" | "legacy" | "date" | "milestone" +type MilestonePreset = "future-birthday" | "anniversary" | "in-one-year" | "custom" +type EntrySource = "free-write" | "prompt" | "ai" | "voice" | "photo" +type DeliveryMethod = "email" | "in-person" // "physical" reserved, disabled +type Intent = "mom" | "dad" | "loved-one" | "undecided" +``` + +--- + +## 7. File storage + +S3 (or R2 / GCS — pick one). Three logical prefixes: + +| Prefix | Content | Retention | +|---|---|---| +| `s3://ember/q-photos/` | Custom-question photos (giver-uploaded) | Lifetime of gift | +| `s3://ember/r-photos/` | Recipient photo entries | Lifetime of journal | +| `s3://ember/r-audio/` | Recipient voice notes (raw) | Lifetime of journal | + +**Audio decision:** keep the raw audio alongside the transcript. The voice in the parent's voice IS the product — losing it to keep only text would gut the experience. Transcripts exist for preview, search, and accessibility. + +**Pre-signed URL pattern** for uploads on the recipient side, since voice notes can be large and we don't want to proxy them through the API. + +**Format constraints:** +- Images cap at ~10 MB. Convert to a normalized format (WebP) on save; keep originals + normalized. +- Audio cap at the Whisper limit (~25 MB). Browser MediaRecorder produces `audio/webm` (Chrome/Firefox) or `audio/mp4` (Safari). Whisper accepts both. Keep both raw and transcript. + +--- + +## 8. Email + +Pick one provider (Resend / Postmark / SendGrid). + +### 8.1 Invitation (sent on `POST /gifts/:id/send`) +- **To:** `Gift.recipientEmail`. +- **From:** `gifts@ember.app` (or whatever). +- **Subject:** something warm, not transactional. Example: `{giverName} sent you something to take your time with.` +- **Body:** designed HTML; reads like a personal letter, not a notification. Includes the email preview line shown on the giver's send screen: `"{recipientName} — I made you something. Open when you have a quiet minute."` Single CTA button to `https://ember.app/r/{accessToken}`. +- **Plain-text fallback** with the same warm tone. + +### 8.2 Share notification (sent on `POST /r/:token/share`) +- **To:** giver's email. +- **Subject:** `{recipientName} shared their journal with you.` +- **Body:** "She/he/they wrote to you. Here's what they shared." Link to a giver-authed archive view (read-only — the giver-side of the parent's shared journal). + +**No other emails.** No "your gift hasn't been opened" nudges, no "new entry written" notifications. Slowness is the feature. + +--- + +## 9. Sharing & archival logic (one-time) + +This is the operational heart of v1's parent side. + +**`POST /r/:token/share` flow:** +1. Read `Sharing.sharedAt`. If non-null → return current state (idempotent). +2. Set `sharedAt = NOW()`. +3. Snapshot `lastSharedSnapshotCount = COUNT(entries WHERE recipientId = ?)`. +4. Compile the archive (server-side render — HTML "book" of all entries ordered by `createdAt`, audio playable inline). Optionally generate PDF/EPUB. +5. Email the giver with the archive link. +6. Mark the recipient's journal as archived (read-only — enforced at write endpoints). + +**Write enforcement:** +- `POST /r/:token/entries` and the photo/audio endpoints must return 409 once `sharedAt` is set. +- The frontend already hides write affordances when archived, but server enforcement is required. + +**Time-lock modes (`mode != "when-ready"`):** +- `date` / `milestone`: a scheduled job checks `WHERE date <= today AND sharedAt IS NULL` and triggers the share automatically. + - **Implementation:** cron tick every hour, or scheduled queue jobs (BullMQ / Cloud Tasks / SQS Delay) enqueued at the chosen date. +- `legacy`: out of scope for v1 implementation — needs a verification mechanism (executor confirmation, "still alive" check-in cadence, etc.). For v1, accept the value but don't auto-trigger. +- `when-ready`: never auto-triggers; only manual. + +--- + +## 10. Migration plan: localStorage → API + +Frontend currently writes to `localStorage` on every `update()`. To migrate without rewriting the UI: + +### Giver side +1. Add a `useGift()` hook with the same shape as `useOnboardingState()` but backed by the API. +2. On Welcome → Begin, `POST /gifts` to create a draft and store the `giftId` in localStorage (only the ID — everything else moves server-side). +3. Each `update()` becomes a debounced `PATCH /gifts/:id` (300ms is fine). +4. On every page mount, `GET /gifts/:id` to hydrate. +5. Photos: replace data-URL storage in `questions/write` with `POST /gifts/:id/questions/:qid/photo` once the question row exists. + +### Parent side +1. Add a `useParentJournal(token)` hook backed by `GET /r/:token` + `GET /r/:token/entries`. +2. Replace each composer save with `POST /r/:token/entries`. +3. Photos: same multipart pattern. +4. Voice: upload audio via multipart `POST /r/:token/entries/:eid/audio`; backend transcribes server-side instead of (or in addition to) client-side `/transcribe`. +5. Sharing state: backed by `GET /r/:token/sharing` + `PATCH /r/:token/sharing`. + +Drop the localStorage layer once API parity is reached. Keep the resume-mid-flow guarantee — `currentStep` on the gift / parent-step on the recipient row plus a generic "land on `/onboarding/{step}` or `/r/[token]/{step}`" router rule handles this. + +--- + +## 11. Environment variables + +Currently in [.env.example](.env.example): + +``` +# XORS auth (required for child-side OAuth — without these /oauth fails) +API_AES_KEY= +API_IV_KEY= +NEXT_PUBLIC_XORS_OAUTH_DOMAIN=REDIRECT_BOSTON +XORS_AUTH_SOURCE=boston-hackathon.local + +# Server +PORT=3001 +CORS_ORIGIN=http://localhost:3000 + +# AI (added in this work) +OPENAI_API_KEY=sk-... # Whisper (transcription on every voice surface) +ANTHROPIC_API_KEY=sk-ant-... # Claude Opus 4.7 (converse + summarize) +``` + +To add for the full backend: + +``` +DATABASE_URL=postgresql://... +S3_BUCKET=ember-prod +S3_REGION=us-east-1 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +EMAIL_PROVIDER_API_KEY= # Resend / Postmark / etc. +EMAIL_FROM=gifts@ember.app +APP_BASE_URL=https://ember.app # invitation links + archive links +SCHEDULED_SHARE_CRON=0 * * * * # if cron-tick model; or skip if using delayed queue +``` + +**Local-dev OAuth:** the production-registered XORS domain (`REDIRECT_BOSTON`) redirects to the prod URL. For local dev sign-in, ask whoever runs `api.xors.xyz` to register `REDIRECT_BOSTON_LOCAL` → `http://localhost:3000/oauth`, then set `NEXT_PUBLIC_XORS_OAUTH_DOMAIN=REDIRECT_BOSTON_LOCAL` in your local `.env`. As a workaround until then, manually edit the host in the redirect URL from `boston-hackathon.up.railway.app` to `localhost:3000` (the same `API_AES_KEY` / `API_IV_KEY` will decrypt locally). + +--- + +## 12. AI prompts (stable text — keep in sync with [server/src/routes/ai.ts](server/src/routes/ai.ts)) + +Three topics, three system prompts. All target **Claude Opus 4.7** (`claude-opus-4-7`). + +- `about` — used on `/onboarding/recipient/ai`. Goal: gather 4–6 small concrete details about the recipient. +- `why` — used on `/onboarding/why/ai`. Goal: help the giver articulate why they want to make this gift. +- `parent-reflect` — used on `/r/:token/ai`. Goal: keep the parent company while they think out loud. + +Summarize prompts mirror each topic. The `parent-reflect` summarizer writes in **first-person from the parent's voice** for direct insertion as a journal entry. + +The conversation system prompts are well under Opus 4.7's 4K-token caching minimum, so prompt caching is currently a no-op. If they grow, place stable content (the persona block) before any volatile content (intent + hint) so the cache prefix stays valid. + +--- + +## 13. Open questions for product + +- **Personal letter UI (giver).** No screen exists yet for the giver to author the letter the parent reads on `/r/[token]/letter`. The parent-side renders a generic warm letter when `personalMessage` is null. Where should this live in the giver flow? Suggest: between Send-confirm and Sent. +- **Giver pronoun.** The parent side says "Read what {she/he/they} wrote." We never collect the giver's pronoun. Quickest fix: a single "How should they refer to you?" radio on the giver `/onboarding/account` step. +- **Time-lock UX (giver).** No screen exists for the giver to set a time-lock date / milestone. (The original v1 spec mentions this; the parent now sets it on their side via `/r/:token/share/when`.) Decide if the time-lock is giver-driven, parent-driven, or both. +- **Re-login routing.** Currently `/login` (recipient path) routes to `/r/{recent-token}/journal`. After backend lookup, it should resolve `email + password → token → journal`. Plus, on `/r/[token]` for a returning visitor, we currently redirect by `step`; should it always go to `/journal` if the parent has any entries? (Implementation detail in [r/[token]/page.tsx](web/app/r/[token]/page.tsx).) +- **Multi-recipient.** Spec says one recipient per gift in v1. Confirm before designing schema flexibility. +- **Edit after send.** Dashboard says "add more questions anytime." Add: yes. Remove a question after the recipient has answered it: probably no (would discard their entries). Confirm. +- **`legacy` time-lock.** Out of scope for v1 implementation; needs a verification flow. +- **Personalized AI question generation.** v1 spec mentions "AI quietly personalizes prompt suggestions based on what the child shared during setup." Sketched as `POST /gifts/:id/suggest-questions`; the prompt design and how suggestions interact with the curated library needs a product call. + +--- + +## 14. Suggested build order + +1. **Postgres + Prisma (or whatever).** Replace the in-memory user store. Schema for `User`, `Gift`, `Person`, `QuestionTemplate`, `Question`, `Recipient`, `JournalEntry`, `Sharing`. Seed `QuestionTemplate` from the frontend library. +2. **Gift CRUD + People + Questions endpoints** (§5.2–5.4). Wire the giver frontend to PATCH on every `update()`. Drop `ember:onboarding:v1` from localStorage. +3. **S3 + photo upload** for custom questions (giver) and photo entries (parent). +4. **Send endpoint + invitation email** + `Recipient` row + tokenized URL. +5. **Recipient endpoints (§5.5)** + parent frontend wired to API. Audio upload + Whisper. +6. **Sharing + archive flow** (§9). Includes `POST /r/:token/share`, server-rendered archive view for the giver, share notification email. +7. **Time-lock cron** for `date` / `milestone` modes. +8. **AI question suggestions** (`POST /gifts/:id/suggest-questions`) + UI integration on the questions browse screen. +9. **Personal letter authoring** for the giver (after product call). +10. **Pronoun collection** (after product call). + +Steps 1–5 unblock the giver-side end-to-end and give the parent a working journal. Step 6 closes the loop. Steps 7–10 are polish/extras. diff --git a/biome.json b/biome.json index a96ec94..410da9f 100644 --- a/biome.json +++ b/biome.json @@ -42,7 +42,12 @@ }, "a11y": { "useValidAnchor": "warn", - "useButtonType": "warn" + "useButtonType": "warn", + "noSvgWithoutTitle": "off", + "noRedundantAlt": "warn" + }, + "performance": { + "noImgElement": "off" } } }, diff --git a/server/src/db/migrations/004_recipient_flow.sql b/server/src/db/migrations/004_recipient_flow.sql new file mode 100644 index 0000000..1a59aa2 --- /dev/null +++ b/server/src/db/migrations/004_recipient_flow.sql @@ -0,0 +1,63 @@ +-- Recipient (parent) flow: journal entries, sharing config, account creds. +-- BACKEND_HANDOFF_V2.md §6.7, §6.8 + §5.1 (recipient login). +-- +-- The existing `responses` table (per-question one-shot answers) stays; +-- the parent flow here is a different shape — free-form journaling that +-- may or may not be tied to a giver-curated prompt. + +CREATE TYPE entry_source AS ENUM ( + 'free-write', 'prompt', 'ai', 'voice', 'photo' +); + +CREATE TYPE sharing_mode AS ENUM ( + 'when-ready', 'legacy', 'date', 'milestone' +); + +CREATE TYPE milestone_preset AS ENUM ( + 'future-birthday', 'anniversary', 'in-one-year', 'custom' +); + +CREATE TABLE journal_entries ( + id text PRIMARY KEY, + recipient_id text NOT NULL REFERENCES recipients(id) ON DELETE CASCADE, + gift_id text NOT NULL REFERENCES gifts(id) ON DELETE CASCADE, + source entry_source NOT NULL, + text text, + prompt_id text, + prompt_text text, + photo_url text, + audio_url text, + duration_seconds integer, + preface text, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX journal_entries_recipient_idx + ON journal_entries (recipient_id, created_at DESC); +CREATE INDEX journal_entries_gift_idx ON journal_entries (gift_id); + +CREATE TABLE sharing ( + recipient_id text PRIMARY KEY + REFERENCES recipients(id) ON DELETE CASCADE, + mode sharing_mode NOT NULL DEFAULT 'when-ready', + date date, + milestone_preset milestone_preset, + milestone_text text, + shared_at timestamptz, + last_shared_snapshot_count integer +); + +-- Recipient account credentials. Set when the parent fills out +-- `/r/:token/account`. Token remains the primary auth surface; the +-- account lets them sign back in on a different device via +-- POST /auth/recipient/login. +ALTER TABLE recipients + ADD COLUMN IF NOT EXISTS password_hash text; +ALTER TABLE recipients + ADD COLUMN IF NOT EXISTS account_created_at timestamptz; +CREATE INDEX IF NOT EXISTS recipients_email_idx ON recipients (email); + +-- Personal letter the giver can author for the parent's /r/:token/letter +-- page. Frontend renders a generic warm letter when null (no UI to set +-- this yet — see BACKEND_HANDOFF_V2 §13). +ALTER TABLE gifts + ADD COLUMN IF NOT EXISTS personal_message text; diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 86e30d5..a05d3bb 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -1,4 +1,5 @@ import { + date, index, integer, pgEnum, @@ -47,6 +48,28 @@ export const responseKindEnum = pgEnum("response_kind", [ "photo", ]); +export const entrySourceEnum = pgEnum("entry_source", [ + "free-write", + "prompt", + "ai", + "voice", + "photo", +]); + +export const sharingModeEnum = pgEnum("sharing_mode", [ + "when-ready", + "legacy", + "date", + "milestone", +]); + +export const milestonePresetEnum = pgEnum("milestone_preset", [ + "future-birthday", + "anniversary", + "in-one-year", + "custom", +]); + export const users = pgTable( "users", { @@ -94,6 +117,7 @@ export const gifts = pgTable( delivery: deliveryEnum("delivery").notNull().default("email"), recipientName: text("recipient_name").notNull().default("Them"), recipientEmail: text("recipient_email"), + personalMessage: text("personal_message"), currentStep: stepEnum("current_step").notNull().default("intent"), status: statusEnum("status").notNull().default("draft"), sentAt: timestamp("sent_at", { withTimezone: true }), @@ -155,12 +179,15 @@ export const recipients = pgTable( accessToken: text("access_token").notNull(), email: text("email").notNull().default(""), name: text("name").notNull().default(""), + passwordHash: text("password_hash"), + accountCreatedAt: timestamp("account_created_at", { withTimezone: true }), firstSeenAt: timestamp("first_seen_at", { withTimezone: true }), lastActiveAt: timestamp("last_active_at", { withTimezone: true }), }, (t) => [ uniqueIndex("recipients_gift_id_uniq").on(t.giftId), uniqueIndex("recipients_access_token_uniq").on(t.accessToken), + index("recipients_email_idx").on(t.email), ], ); @@ -190,3 +217,43 @@ export const responses = pgTable( index("responses_question_id_idx").on(t.questionId), ], ); + +export const journalEntries = pgTable( + "journal_entries", + { + id: text("id").primaryKey(), + recipientId: text("recipient_id") + .notNull() + .references(() => recipients.id, { onDelete: "cascade" }), + giftId: text("gift_id") + .notNull() + .references(() => gifts.id, { onDelete: "cascade" }), + source: entrySourceEnum("source").notNull(), + text: text("text"), + promptId: text("prompt_id"), + promptText: text("prompt_text"), + photoUrl: text("photo_url"), + audioUrl: text("audio_url"), + durationSeconds: integer("duration_seconds"), + preface: text("preface"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + index("journal_entries_recipient_idx").on(t.recipientId, t.createdAt), + index("journal_entries_gift_idx").on(t.giftId), + ], +); + +export const sharing = pgTable("sharing", { + recipientId: text("recipient_id") + .primaryKey() + .references(() => recipients.id, { onDelete: "cascade" }), + mode: sharingModeEnum("mode").notNull().default("when-ready"), + date: date("date"), + milestonePreset: milestonePresetEnum("milestone_preset"), + milestoneText: text("milestone_text"), + sharedAt: timestamp("shared_at", { withTimezone: true }), + lastSharedSnapshotCount: integer("last_shared_snapshot_count"), +}); diff --git a/server/src/lib/gift-store.ts b/server/src/lib/gift-store.ts index eaeaf99..929fc6a 100644 --- a/server/src/lib/gift-store.ts +++ b/server/src/lib/gift-store.ts @@ -2,10 +2,12 @@ import { and, asc, desc, eq, gt, sql } from "drizzle-orm"; import { db } from "../db/client"; import { gifts as giftsTable, + journalEntries as journalEntriesTable, people as peopleTable, questions as questionsTable, recipients as recipientsTable, responses as responsesTable, + sharing as sharingTable, } from "../db/schema"; import { genId, genToken } from "./ids"; @@ -54,6 +56,7 @@ export interface Recipient { accessToken: string; email: string; name: string; + accountCreatedAt: string | null; firstSeenAt: string | null; lastActiveAt: string | null; } @@ -81,6 +84,7 @@ export interface Gift { delivery: GiftDelivery; recipientName: string; recipientEmail: string | null; + personalMessage: string | null; currentStep: OnboardingStep; status: GiftStatus; sentAt: string | null; @@ -117,6 +121,7 @@ function rowToGift(row: DbGift): Gift { delivery: row.delivery, recipientName: row.recipientName, recipientEmail: row.recipientEmail, + personalMessage: row.personalMessage, currentStep: row.currentStep, status: row.status, sentAt: isoOrNull(row.sentAt), @@ -160,6 +165,7 @@ function rowToRecipient(row: DbRecipient): Recipient { accessToken: row.accessToken, email: row.email, name: row.name, + accountCreatedAt: isoOrNull(row.accountCreatedAt), firstSeenAt: isoOrNull(row.firstSeenAt), lastActiveAt: isoOrNull(row.lastActiveAt), }; @@ -241,6 +247,7 @@ export type GiftPatch = Partial< | "delivery" | "recipientName" | "recipientEmail" + | "personalMessage" | "currentStep" | "timeLockAt" > @@ -261,6 +268,7 @@ export async function patchGift( "delivery", "recipientName", "recipientEmail", + "personalMessage", "currentStep", ]); if (patch.timeLockAt !== undefined) { @@ -745,3 +753,393 @@ export async function deleteResponse( .returning({ id: responsesTable.id }); return result.length > 0; } + +// -------- Journal entries (parent flow) -------- + +export type EntrySource = "free-write" | "prompt" | "ai" | "voice" | "photo"; + +export interface JournalEntry { + id: string; + recipientId: string; + giftId: string; + source: EntrySource; + text: string | null; + promptId: string | null; + promptText: string | null; + photoUrl: string | null; + audioUrl: string | null; + durationSeconds: number | null; + preface: string | null; + createdAt: string; +} + +type DbJournalEntry = typeof journalEntriesTable.$inferSelect; + +function rowToEntry(row: DbJournalEntry): JournalEntry { + return { + id: row.id, + recipientId: row.recipientId, + giftId: row.giftId, + source: row.source, + text: row.text, + promptId: row.promptId, + promptText: row.promptText, + photoUrl: row.photoUrl, + audioUrl: row.audioUrl, + durationSeconds: row.durationSeconds, + preface: row.preface, + createdAt: row.createdAt.toISOString(), + }; +} + +export interface CreateEntryInput { + source: EntrySource; + text?: string | null; + promptId?: string | null; + promptText?: string | null; + photoUrl?: string | null; + audioUrl?: string | null; + durationSeconds?: number | null; + preface?: string | null; +} + +export async function createEntry( + giftId: string, + recipientId: string, + input: CreateEntryInput, +): Promise { + const [row] = await db + .insert(journalEntriesTable) + .values({ + id: genId("e"), + giftId, + recipientId, + source: input.source, + text: input.text ?? null, + promptId: input.promptId ?? null, + promptText: input.promptText ?? null, + photoUrl: input.photoUrl ?? null, + audioUrl: input.audioUrl ?? null, + durationSeconds: input.durationSeconds ?? null, + preface: input.preface ?? null, + }) + .returning(); + return rowToEntry(row); +} + +export async function listEntries( + recipientId: string, +): Promise { + const rows = await db + .select() + .from(journalEntriesTable) + .where(eq(journalEntriesTable.recipientId, recipientId)) + .orderBy(desc(journalEntriesTable.createdAt)); + return rows.map(rowToEntry); +} + +export async function getEntry( + recipientId: string, + eid: string, +): Promise { + const [row] = await db + .select() + .from(journalEntriesTable) + .where( + and( + eq(journalEntriesTable.recipientId, recipientId), + eq(journalEntriesTable.id, eid), + ), + ); + return row ? rowToEntry(row) : null; +} + +export type EntryPatch = Partial< + Pick +> & { + photoUrl?: string | null; + audioUrl?: string | null; +}; + +export async function patchEntry( + recipientId: string, + eid: string, + patch: EntryPatch, +): Promise { + const update: Partial = {}; + assignDefined(update, patch, [ + "text", + "promptText", + "preface", + "durationSeconds", + "photoUrl", + "audioUrl", + ]); + if (Object.keys(update).length === 0) { + return getEntry(recipientId, eid); + } + const [row] = await db + .update(journalEntriesTable) + .set(update) + .where( + and( + eq(journalEntriesTable.recipientId, recipientId), + eq(journalEntriesTable.id, eid), + ), + ) + .returning(); + return row ? rowToEntry(row) : null; +} + +export async function deleteEntry( + recipientId: string, + eid: string, +): Promise { + const removed = await db + .delete(journalEntriesTable) + .where( + and( + eq(journalEntriesTable.recipientId, recipientId), + eq(journalEntriesTable.id, eid), + ), + ) + .returning({ id: journalEntriesTable.id }); + return removed.length > 0; +} + +// -------- Sharing (parent flow) -------- + +export type SharingMode = "when-ready" | "legacy" | "date" | "milestone"; +export type MilestonePreset = + | "future-birthday" + | "anniversary" + | "in-one-year" + | "custom"; + +export interface SharingState { + recipientId: string; + mode: SharingMode; + date: string | null; + milestonePreset: MilestonePreset | null; + milestoneText: string | null; + sharedAt: string | null; + lastSharedSnapshotCount: number | null; +} + +type DbSharing = typeof sharingTable.$inferSelect; + +function rowToSharing(row: DbSharing): SharingState { + return { + recipientId: row.recipientId, + mode: row.mode, + date: row.date, + milestonePreset: row.milestonePreset, + milestoneText: row.milestoneText, + sharedAt: isoOrNull(row.sharedAt), + lastSharedSnapshotCount: row.lastSharedSnapshotCount, + }; +} + +const DEFAULT_SHARING_STATE = (recipientId: string): SharingState => ({ + recipientId, + mode: "when-ready", + date: null, + milestonePreset: null, + milestoneText: null, + sharedAt: null, + lastSharedSnapshotCount: null, +}); + +export async function getSharing(recipientId: string): Promise { + const [row] = await db + .select() + .from(sharingTable) + .where(eq(sharingTable.recipientId, recipientId)); + if (!row) return DEFAULT_SHARING_STATE(recipientId); + return rowToSharing(row); +} + +export interface SharingPatch { + mode?: SharingMode; + date?: string | null; + milestonePreset?: MilestonePreset | null; + milestoneText?: string | null; +} + +// Once `sharedAt` is set, the journal is archived — block writes +// to keep the snapshot stable. +export class SharingLockedError extends Error { + constructor(message = "Journal is shared and locked") { + super(message); + } +} + +export async function patchSharing( + recipientId: string, + patch: SharingPatch, +): Promise { + const current = await getSharing(recipientId); + if (current.sharedAt) throw new SharingLockedError(); + + const values = { + recipientId, + mode: patch.mode ?? current.mode, + date: patch.date !== undefined ? patch.date : current.date, + milestonePreset: + patch.milestonePreset !== undefined + ? patch.milestonePreset + : current.milestonePreset, + milestoneText: + patch.milestoneText !== undefined + ? patch.milestoneText + : current.milestoneText, + }; + + // Upsert (recipient_id is the PK). + const [row] = await db + .insert(sharingTable) + .values(values) + .onConflictDoUpdate({ + target: sharingTable.recipientId, + set: { + mode: values.mode, + date: values.date, + milestonePreset: values.milestonePreset, + milestoneText: values.milestoneText, + }, + }) + .returning(); + return rowToSharing(row); +} + +export interface ShareResult { + sharing: SharingState; + alreadyShared: boolean; +} + +export async function performShare( + recipientId: string, +): Promise { + const current = await getSharing(recipientId); + if (current.sharedAt) { + return { sharing: current, alreadyShared: true }; + } + + return db.transaction(async (tx) => { + const [{ count }] = await tx + .select({ count: sql`count(*)::int` }) + .from(journalEntriesTable) + .where(eq(journalEntriesTable.recipientId, recipientId)); + + const now = new Date(); + const [row] = await tx + .insert(sharingTable) + .values({ + recipientId, + mode: current.mode, + date: current.date, + milestonePreset: current.milestonePreset, + milestoneText: current.milestoneText, + sharedAt: now, + lastSharedSnapshotCount: Number(count), + }) + .onConflictDoUpdate({ + target: sharingTable.recipientId, + set: { + sharedAt: now, + lastSharedSnapshotCount: Number(count), + }, + }) + .returning(); + return { sharing: rowToSharing(row), alreadyShared: false }; + }); +} + +export async function isJournalArchived(recipientId: string): Promise { + const [row] = await db + .select({ sharedAt: sharingTable.sharedAt }) + .from(sharingTable) + .where(eq(sharingTable.recipientId, recipientId)); + return Boolean(row?.sharedAt); +} + +// -------- Recipient account credentials -------- + +// Stored as `bun-bcrypt` style salted hash; we lean on Bun's +// `password.hash`/`verify` rather than pulling in a bcrypt dep. +export async function setRecipientCredentials( + token: string, + email: string, + password: string, +): Promise { + const hash = await Bun.password.hash(password); + const [row] = await db + .update(recipientsTable) + .set({ + email: email.trim().toLowerCase(), + passwordHash: hash, + accountCreatedAt: sql`coalesce(${recipientsTable.accountCreatedAt}, now())`, + }) + .where(eq(recipientsTable.accessToken, token)) + .returning(); + return row ? rowToRecipient(row) : null; +} + +export async function recipientLogin( + email: string, + password: string, +): Promise { + const normalized = email.trim().toLowerCase(); + const [row] = await db + .select() + .from(recipientsTable) + .where(eq(recipientsTable.email, normalized)); + if (!row?.passwordHash) return null; + const ok = await Bun.password.verify(password, row.passwordHash); + if (!ok) return null; + return rowToRecipient(row); +} + +// Used by the recipient-side login + by tests that need to know whether +// an email is already taken without leaking the outcome to the caller. +export async function recipientHasAccount(token: string): Promise { + const [row] = await db + .select({ accountCreatedAt: recipientsTable.accountCreatedAt }) + .from(recipientsTable) + .where(eq(recipientsTable.accessToken, token)); + return Boolean(row?.accountCreatedAt); +} + +// -------- Eager loaders for the recipient envelope -------- + +export async function listEntriesForToken( + token: string, +): Promise { + const found = await loadByToken(token); + if (!found) return null; + return listEntries(found.recipient.id); +} + +// Convenience used by routes that need to confirm a token is valid AND +// that the journal is not yet archived in a single round-trip. +export async function loadByTokenWithGuards( + token: string, +): Promise< + { gift: Gift; recipient: Recipient; sharing: SharingState; archived: boolean } | null +> { + const found = await loadByToken(token); + if (!found) return null; + const sharing = await getSharing(found.recipient.id); + return { + ...found, + sharing, + archived: Boolean(sharing.sharedAt), + }; +} + +// Test-only helper to wipe parent-flow rows between integration runs +// without touching gifts/users. +export async function _resetRecipientDataForTests(): Promise { + await db.delete(journalEntriesTable); + await db.delete(sharingTable); +} diff --git a/server/src/lib/route-helpers.ts b/server/src/lib/route-helpers.ts index 378b22b..0cab687 100644 --- a/server/src/lib/route-helpers.ts +++ b/server/src/lib/route-helpers.ts @@ -28,6 +28,60 @@ export const responseSchema = t.Object({ recordedAt: t.String(), }); +export const entrySourceSchema = t.Union([ + t.Literal("free-write"), + t.Literal("prompt"), + t.Literal("ai"), + t.Literal("voice"), + t.Literal("photo"), +]); + +export const journalEntrySchema = t.Object({ + id: t.String(), + recipientId: t.String(), + giftId: t.String(), + source: entrySourceSchema, + text: t.Union([t.String(), t.Null()]), + promptId: t.Union([t.String(), t.Null()]), + promptText: t.Union([t.String(), t.Null()]), + photoUrl: t.Union([t.String(), t.Null()]), + audioUrl: t.Union([t.String(), t.Null()]), + durationSeconds: t.Union([t.Number(), t.Null()]), + preface: t.Union([t.String(), t.Null()]), + createdAt: t.String(), +}); + +export const sharingModeSchema = t.Union([ + t.Literal("when-ready"), + t.Literal("legacy"), + t.Literal("date"), + t.Literal("milestone"), +]); + +export const milestonePresetSchema = t.Union([ + t.Literal("future-birthday"), + t.Literal("anniversary"), + t.Literal("in-one-year"), + t.Literal("custom"), +]); + +export const sharingStateSchema = t.Object({ + recipientId: t.String(), + mode: sharingModeSchema, + date: t.Union([t.String(), t.Null()]), + milestonePreset: t.Union([milestonePresetSchema, t.Null()]), + milestoneText: t.Union([t.String(), t.Null()]), + sharedAt: t.Union([t.String(), t.Null()]), + lastSharedSnapshotCount: t.Union([t.Number(), t.Null()]), +}); + +export const parentPromptSchema = t.Object({ + id: t.String(), + text: t.String(), + preface: t.Union([t.String(), t.Null()]), + photoUrl: t.Union([t.String(), t.Null()]), +}); + interface RouteCtx { currentUser: AppUser | null; set: { status?: number | string }; diff --git a/server/src/routes/ai.ts b/server/src/routes/ai.ts index 43521d3..a177498 100644 --- a/server/src/routes/ai.ts +++ b/server/src/routes/ai.ts @@ -96,7 +96,44 @@ Rules: - Never speak as the recipient. - Output ONLY the prose paragraph. No preamble, no headers, no bullets, no quote marks around the whole thing.`; -const TopicSchema = t.Union([t.Literal("about"), t.Literal("why")]); +const PARENT_REFLECT_SYSTEM = `You are Ember, a warm and curious helper for someone who is sitting down to write — for themselves first, and possibly for someone they love. + +Your job: help them think out loud. Pull on threads, ask one short question at a time, and let them follow where it goes. They might be writing about anything — a memory, a feeling, a person, a moment. You're not extracting information; you're keeping them company while they find the words. + +How you talk: +- Warm. Present-tense. Alive. Never grief-coded unless they bring it up. +- One short message per turn. Often just a sentence or two. End with a single question. +- Listen for the specific. When they mention something concrete (a phrase, a smell, a sound, a moment), pull on that thread. +- Reflect briefly in your own words before asking the next — but don't over-validate. +- No platitudes ("how meaningful", "what a beautiful memory"). No therapist voice. Just curiosity. +- It's ok to sit in silence. If they say something short, don't pile on more questions. A simple "tell me more" or one specific follow-up is plenty. +- Never simulate or speak for anyone else in their life. + +Format: plain prose. No headers, no bullets, no emoji. Short. + +If this is the first turn, open with a small, warm, low-pressure question — something easy to start with. Don't greet, don't introduce yourself.`; + +const SUMMARIZE_PARENT_REFLECT_SYSTEM = `You distill a conversation into a journal entry written in the speaker's voice. + +Capture: +- the actual content they shared, not your questions +- their phrases, their specifics, their voice +- the texture and small details, not abstractions + +Rules: +- First-person, written AS them ("I remember…", "I can still see…"). +- Present-tense unless they were clearly recounting a past moment. +- No therapist voice. No platitudes. No meta-commentary about the conversation itself. +- Output ONLY the prose journal entry. No preamble, no "here's a summary", no headers, no bullets, no quote marks around the whole thing. +- Length: as long as needed to honor what they said. A paragraph, or several.`; + +const TopicSchema = t.Union([ + t.Literal("about"), + t.Literal("why"), + t.Literal("parent-reflect"), +]); + +type Topic = "about" | "why" | "parent-reflect"; const MessageSchema = t.Object({ role: t.Union([t.Literal("user"), t.Literal("assistant")]), @@ -111,14 +148,16 @@ function extractText(blocks: Anthropic.Messages.ContentBlock[]): string { .trim(); } -function systemFor(topic: "about" | "why", intent: string, hint?: string) { - return topic === "why" - ? buildWhySystem(intent, hint) - : buildAboutSystem(intent, hint); +function systemFor(topic: Topic, intent: string, hint?: string) { + if (topic === "parent-reflect") return PARENT_REFLECT_SYSTEM; + if (topic === "why") return buildWhySystem(intent, hint); + return buildAboutSystem(intent, hint); } -function summarizeSystemFor(topic: "about" | "why") { - return topic === "why" ? SUMMARIZE_WHY_SYSTEM : SUMMARIZE_ABOUT_SYSTEM; +function summarizeSystemFor(topic: Topic) { + if (topic === "parent-reflect") return SUMMARIZE_PARENT_REFLECT_SYSTEM; + if (topic === "why") return SUMMARIZE_WHY_SYSTEM; + return SUMMARIZE_ABOUT_SYSTEM; } export const aiRoutes = new Elysia({ prefix: "/ai" }) diff --git a/server/src/routes/auth-login.integration.test.ts b/server/src/routes/auth-login.integration.test.ts index 05d9dbc..d3ddb24 100644 --- a/server/src/routes/auth-login.integration.test.ts +++ b/server/src/routes/auth-login.integration.test.ts @@ -11,13 +11,21 @@ // per run (which will create one new account in the xors users // table — that's the cost of opt-in network testing). -import { describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { Elysia } from "elysia"; import { authRoutes } from "./auth"; const SHOULD_RUN = process.env.INTEGRATION === "1"; const dscribe = SHOULD_RUN ? describe : describe.skip; +// Other integration tests (recipient.*, recipient-flow.*) set +// TEST_USER_EMAIL in their beforeAll to bypass XORS session lookup. +// That env var is process-global and leaks across files in the same +// `bun test` run — left set, the auth context here would short-circuit +// to the fake test user instead of decoding the real session cookie +// from /auth/login. Stash + clear for the duration of this file. +let savedTestUserEmail: string | undefined; + const TEST_EMAIL = process.env.INTEGRATION_TEST_EMAIL || `boston-hackathon-test-${Date.now()}@example.test`; @@ -34,6 +42,19 @@ function extractSessionCookie(setCookie: string | null): string | null { return match?.[1] ?? null; } +beforeAll(() => { + if (!SHOULD_RUN) return; + savedTestUserEmail = process.env.TEST_USER_EMAIL; + delete process.env.TEST_USER_EMAIL; +}); + +afterAll(() => { + if (!SHOULD_RUN) return; + if (savedTestUserEmail !== undefined) { + process.env.TEST_USER_EMAIL = savedTestUserEmail; + } +}); + dscribe("integration: real api.xors.xyz round-trip", () => { it( "login → /auth/me → wrong-password 401", @@ -75,7 +96,10 @@ dscribe("integration: real api.xors.xyz round-trip", () => { user: { id: string; email: string; displayName: string | null }; }; expect(me.user.email).toBe(TEST_EMAIL.toLowerCase()); - expect(me.user.id).toMatch(/^usr_/); + // xors id is opaque — was prefixed `usr_` historically, today + // it's a bare UUID. Just assert it's a non-empty string. + expect(typeof me.user.id).toBe("string"); + expect(me.user.id.length).toBeGreaterThan(0); // Re-login with same creds (existing-user path). const loginAgain = await app.handle( diff --git a/server/src/routes/auth-login.test.ts b/server/src/routes/auth-login.test.ts index d70643c..34d6a3c 100644 --- a/server/src/routes/auth-login.test.ts +++ b/server/src/routes/auth-login.test.ts @@ -42,7 +42,11 @@ function stubAuthenticateFetch( } return realFetch(input, init); }) as typeof fetch; - return { restore: () => (globalThis.fetch = realFetch) }; + return { + restore: () => { + globalThis.fetch = realFetch; + }, + }; } describe("POST /auth/login", () => { diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index a3e8353..d039cbe 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -1,4 +1,5 @@ import { Elysia, t } from "elysia"; +import { recipientLogin } from "../lib/gift-store"; import { authContext, XORS_SESSION_COOKIE } from "../lib/xors-identity"; const COOKIE_SECURE = @@ -131,4 +132,41 @@ export const authRoutes = new Elysia({ prefix: "/auth" }) response: t.Object({ ok: t.Literal(true) }), detail: { summary: "Clear session cookie", tags: ["Auth"] }, }, + ) + // Parent (recipient) login. Distinct from the giver path above — + // no XORS round-trip; we look up the recipient by email and verify + // their stored password hash. Returns the access token so the + // caller can navigate to /r/:token directly. + .post( + "/recipient/login", + async ({ body, set }) => { + const recipient = await recipientLogin(body.email, body.password); + if (!recipient) { + set.status = 401; + return { error: "Wrong email or password." }; + } + return { + ok: true as const, + token: recipient.accessToken, + redirectTo: `/r/${recipient.accessToken}/journal`, + }; + }, + { + body: t.Object({ + email: t.String({ minLength: 3, maxLength: 320 }), + password: t.String({ minLength: 1, maxLength: 256 }), + }), + response: { + 200: t.Object({ + ok: t.Literal(true), + token: t.String(), + redirectTo: t.String(), + }), + 401: t.Object({ error: t.String() }), + }, + detail: { + summary: "Sign in as a recipient (parent flow) by email + password", + tags: ["Auth"], + }, + }, ); diff --git a/server/src/routes/gifts.ts b/server/src/routes/gifts.ts index e6f472a..7d00f5b 100644 --- a/server/src/routes/gifts.ts +++ b/server/src/routes/gifts.ts @@ -83,6 +83,7 @@ const giftSchema = t.Object({ delivery: deliverySchema, recipientName: t.String(), recipientEmail: t.Union([t.String(), t.Null()]), + personalMessage: t.Union([t.String(), t.Null()]), currentStep: stepSchema, status: statusSchema, sentAt: t.Union([t.String(), t.Null()]), @@ -197,6 +198,9 @@ export const giftsRoutes = new Elysia({ prefix: "/gifts" }) delivery: t.Optional(deliverySchema), recipientName: t.Optional(t.String({ maxLength: 200 })), recipientEmail: t.Optional(t.Union([t.String({ maxLength: 320 }), t.Null()])), + personalMessage: t.Optional( + t.Union([t.String({ maxLength: 8000 }), t.Null()]), + ), currentStep: t.Optional(stepSchema), timeLockAt: t.Optional(t.Union([t.String(), t.Null()])), }), diff --git a/server/src/routes/messages.test.ts b/server/src/routes/messages.test.ts index b7b299f..cc30bc1 100644 --- a/server/src/routes/messages.test.ts +++ b/server/src/routes/messages.test.ts @@ -76,7 +76,11 @@ function stubViewerFetch(byKey: Record): { } return realFetch(input, init); }) as typeof fetch; - return { restore: () => (globalThis.fetch = realFetch) }; + return { + restore: () => { + globalThis.fetch = realFetch; + }, + }; } describe("messages routes — authenticated", () => { diff --git a/server/src/routes/recipient-flow.integration.test.ts b/server/src/routes/recipient-flow.integration.test.ts new file mode 100644 index 0000000..f4237d3 --- /dev/null +++ b/server/src/routes/recipient-flow.integration.test.ts @@ -0,0 +1,626 @@ +// Integration tests for the parent (recipient) flow added in +// BACKEND_HANDOFF_V2.md §4C/§5.5: journal entries CRUD + photo/audio +// uploads, sharing config + one-time share, recipient account +// credentials + login. +// +// Same harness as recipient.integration.test.ts: +// +// DATABASE_URL=postgresql://localhost:5432/ember_test \ +// INTEGRATION=1 bun test src/routes/recipient-flow.integration.test.ts +// +// Stubs OpenAI Whisper via globalThis fetch override so audio uploads +// don't need an API key. + +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { Elysia } from "elysia"; +import { db } from "../db/client"; +import { users } from "../db/schema"; +import { + _resetRecipientDataForTests, + addQuestion, + createGift, + patchGift, + sendGift, +} from "../lib/gift-store"; +import { + _setStorageForTests, + createInMemoryStorage, + type InMemoryStorage, +} from "../lib/storage"; +import { authRoutes } from "./auth"; +import { giftsRoutes } from "./gifts"; +import { recipientRoutes } from "./recipient"; + +const SHOULD_RUN = process.env.INTEGRATION === "1"; +const dscribe = SHOULD_RUN ? describe : describe.skip; + +const TEST_EMAIL = `recipient-flow-it-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.local`; +// Per-process suffix so account creds we set on `recipients.email` don't +// collide with leftover rows from prior runs of this test file. +const PROC_SUFFIX = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +const procEmail = (prefix: string) => `${prefix}-${PROC_SUFFIX}@test.local`; + +function buildApp() { + return new Elysia().use(authRoutes).use(giftsRoutes).use(recipientRoutes); +} + +const realFetch = globalThis.fetch; +let storage: InMemoryStorage; + +function stubWhisper(textToReturn: string, opts?: { fail?: boolean }) { + globalThis.fetch = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("api.openai.com/v1/audio/transcriptions")) { + if (opts?.fail) { + return new Response("upstream error", { status: 500 }); + } + return new Response(JSON.stringify({ text: textToReturn }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return realFetch(input, init); + }) as typeof fetch; +} + +async function ensureUser(xorsUserId: string, email: string) { + await db + .insert(users) + .values({ xorsUserId, email }) + .onConflictDoNothing(); +} + +async function seed() { + const xorsUserId = `test-${TEST_EMAIL}`; + await ensureUser(xorsUserId, TEST_EMAIL); + const gift0 = await createGift(xorsUserId, "mom"); + const gift = + (await patchGift(gift0.id, xorsUserId, { + recipientName: "Mom", + recipientEmail: "mom-flow@test.local", + personalMessage: "Mom, I made you something. Open whenever.", + delivery: "email", + })) ?? gift0; + const q1 = await addQuestion(gift.id, { + source: "library", + templateId: "tpl_ch1", + text: "What was your favorite room growing up?", + }); + const q2 = await addQuestion(gift.id, { + source: "custom", + text: "Tell me about your wedding day.", + }); + const q3 = await addQuestion(gift.id, { + source: "custom", + text: "What were you thinking on the drive home from the hospital?", + }); + const sent = await sendGift(gift.id, xorsUserId); + return { gift, q1, q2, q3, recipient: sent.recipient, xorsUserId }; +} + +beforeAll(() => { + // Skip when not running integration — `process.env` is process-global, + // so leaving TEST_USER_EMAIL set here would short-circuit + // authContext.maybeTestUser() in every other test file's request. + if (!SHOULD_RUN) return; + process.env.TEST_USER_EMAIL = TEST_EMAIL; + process.env.OPENAI_API_KEY = "sk-test-stub"; +}); + +beforeEach(async () => { + storage = createInMemoryStorage(); + _setStorageForTests(storage); + if (SHOULD_RUN) { + await _resetRecipientDataForTests(); + } +}); + +afterEach(() => { + globalThis.fetch = realFetch; + _setStorageForTests(null); +}); + +dscribe("GET /r/:token envelope", () => { + it("returns giver, recipient, prompts, sharing, archived flag", async () => { + stubWhisper(""); + const { recipient } = await seed(); + const res = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}`), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + giver: { recipientName: string }; + recipient: { name: string; email: string; accountCreated: boolean }; + personalMessage: string | null; + prompts: Array<{ id: string; text: string }>; + sharing: { mode: string; sharedAt: string | null }; + archived: boolean; + }; + expect(body.giver.recipientName).toBe("Mom"); + expect(body.recipient.name).toBe("Mom"); + expect(body.recipient.accountCreated).toBe(false); + expect(body.personalMessage).toContain("I made you something"); + expect(body.prompts).toHaveLength(3); + expect(body.sharing.mode).toBe("when-ready"); + expect(body.sharing.sharedAt).toBeNull(); + expect(body.archived).toBe(false); + }); +}); + +dscribe("GET /r/:token/prompts", () => { + it("returns prompts shaped from the giver's questions", async () => { + const { recipient, q1 } = await seed(); + const res = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/prompts`), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + prompts: Array<{ id: string; text: string; preface: string | null }>; + }; + expect(body.prompts.find((p) => p.id === q1.id)?.text).toBe( + "What was your favorite room growing up?", + ); + }); +}); + +dscribe("Journal entries CRUD", () => { + it("creates a free-write entry and lists it", async () => { + const { recipient } = await seed(); + const post = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/entries`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + source: "free-write", + text: "the morning light through the kitchen window", + }), + }), + ); + expect(post.status).toBe(200); + const { entry } = (await post.json()) as { + entry: { id: string; source: string; text: string }; + }; + expect(entry.source).toBe("free-write"); + expect(entry.text).toBe("the morning light through the kitchen window"); + + const list = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/entries`), + ); + const { entries } = (await list.json()) as { + entries: Array<{ id: string }>; + }; + expect(entries).toHaveLength(1); + expect(entries[0].id).toBe(entry.id); + }); + + it("snapshots promptText from the giver question on prompt entries", async () => { + const { recipient, q1 } = await seed(); + const post = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/entries`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + source: "prompt", + promptId: q1.id, + text: "the kitchen", + }), + }), + ); + const { entry } = (await post.json()) as { + entry: { promptId: string | null; promptText: string | null }; + }; + expect(entry.promptId).toBe(q1.id); + expect(entry.promptText).toBe("What was your favorite room growing up?"); + }); + + it("PATCH updates text", async () => { + const { recipient } = await seed(); + const post = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/entries`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: "free-write", text: "first draft" }), + }), + ); + const { entry } = (await post.json()) as { entry: { id: string } }; + const patch = await buildApp().handle( + new Request( + `http://localhost/r/${recipient.accessToken}/entries/${entry.id}`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: "second draft, more honest" }), + }, + ), + ); + expect(patch.status).toBe(200); + const { entry: updated } = (await patch.json()) as { + entry: { text: string }; + }; + expect(updated.text).toBe("second draft, more honest"); + }); + + it("DELETE removes the entry", async () => { + const { recipient } = await seed(); + const post = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/entries`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: "free-write", text: "delete me" }), + }), + ); + const { entry } = (await post.json()) as { entry: { id: string } }; + const del = await buildApp().handle( + new Request( + `http://localhost/r/${recipient.accessToken}/entries/${entry.id}`, + { method: "DELETE" }, + ), + ); + expect(del.status).toBe(200); + const list = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/entries`), + ); + const { entries } = (await list.json()) as { entries: Array }; + expect(entries).toHaveLength(0); + }); +}); + +dscribe("Entry photo + audio uploads", () => { + it("uploads a photo to a photo entry", async () => { + const { recipient } = await seed(); + const post = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/entries`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: "photo", text: "june 4th, sunny" }), + }), + ); + const { entry } = (await post.json()) as { entry: { id: string } }; + const fd = new FormData(); + fd.append( + "photo", + new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "wedding.png", { + type: "image/png", + }), + ); + const upload = await buildApp().handle( + new Request( + `http://localhost/r/${recipient.accessToken}/entries/${entry.id}/photo`, + { method: "POST", body: fd }, + ), + ); + expect(upload.status).toBe(200); + const { entry: updated, photoUrl } = (await upload.json()) as { + entry: { photoUrl: string | null }; + photoUrl: string; + }; + expect(photoUrl).toMatch(/r-photos\/.*\.png$/); + expect(updated.photoUrl).toBe(photoUrl); + expect(storage._dump().some((d) => d.key.startsWith("r-photos/"))).toBe( + true, + ); + }); + + it("uploads audio + transcribes + stores duration", async () => { + stubWhisper("the sound of the rain on the kitchen window"); + const { recipient } = await seed(); + const post = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/entries`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: "voice" }), + }), + ); + const { entry } = (await post.json()) as { entry: { id: string } }; + const fd = new FormData(); + fd.append( + "audio", + new File([new Uint8Array([1, 2, 3, 4])], "note.wav", { + type: "audio/wav", + }), + ); + fd.append("durationSeconds", "42"); + const upload = await buildApp().handle( + new Request( + `http://localhost/r/${recipient.accessToken}/entries/${entry.id}/audio`, + { method: "POST", body: fd }, + ), + ); + expect(upload.status).toBe(200); + const body = (await upload.json()) as { + audioUrl: string; + transcript: string; + durationSeconds: number | null; + entry: { + audioUrl: string | null; + text: string | null; + durationSeconds: number | null; + }; + }; + expect(body.audioUrl).toMatch(/r-audio\//); + expect(body.transcript).toBe( + "the sound of the rain on the kitchen window", + ); + expect(body.durationSeconds).toBe(42); + expect(body.entry.text).toBe(body.transcript); + }); + + it("audio upload still saves audio when Whisper fails", async () => { + stubWhisper("", { fail: true }); + const { recipient } = await seed(); + const post = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/entries`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: "voice", text: "fallback text" }), + }), + ); + const { entry } = (await post.json()) as { entry: { id: string } }; + const fd = new FormData(); + fd.append( + "audio", + new File([new Uint8Array([7])], "x.wav", { type: "audio/wav" }), + ); + const upload = await buildApp().handle( + new Request( + `http://localhost/r/${recipient.accessToken}/entries/${entry.id}/audio`, + { method: "POST", body: fd }, + ), + ); + expect(upload.status).toBe(200); + const body = (await upload.json()) as { + audioUrl: string; + transcript: string; + entry: { audioUrl: string | null; text: string | null }; + }; + expect(body.audioUrl).toMatch(/r-audio\//); + // Whisper failed → preserves existing text on the entry instead + // of overwriting with an empty transcript. + expect(body.transcript).toBe("fallback text"); + expect(body.entry.text).toBe("fallback text"); + }); +}); + +dscribe("Sharing", () => { + it("PATCH /sharing updates mode + date", async () => { + const { recipient } = await seed(); + const res = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/sharing`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "date", date: "2027-06-04" }), + }), + ); + expect(res.status).toBe(200); + const { sharing } = (await res.json()) as { + sharing: { mode: string; date: string | null }; + }; + expect(sharing.mode).toBe("date"); + expect(sharing.date).toBe("2027-06-04"); + }); + + it("POST /share is one-time + idempotent", async () => { + const { recipient } = await seed(); + + // Pre-share a couple of entries so snapshot count is meaningful. + await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/entries`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: "free-write", text: "one" }), + }), + ); + await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/entries`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: "free-write", text: "two" }), + }), + ); + + const first = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/share`, { + method: "POST", + }), + ); + expect(first.status).toBe(200); + const firstBody = (await first.json()) as { + sharing: { + sharedAt: string | null; + lastSharedSnapshotCount: number | null; + }; + alreadyShared: boolean; + }; + expect(firstBody.alreadyShared).toBe(false); + expect(firstBody.sharing.sharedAt).not.toBeNull(); + expect(firstBody.sharing.lastSharedSnapshotCount).toBe(2); + + const second = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/share`, { + method: "POST", + }), + ); + const secondBody = (await second.json()) as { + alreadyShared: boolean; + sharing: { sharedAt: string | null }; + }; + expect(secondBody.alreadyShared).toBe(true); + // sharedAt is stable on the second call (snapshot frozen). + expect(secondBody.sharing.sharedAt).toBe(firstBody.sharing.sharedAt); + }); + + it("write endpoints return 409 once sharing is locked", async () => { + const { recipient } = await seed(); + await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/share`, { + method: "POST", + }), + ); + const post = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/entries`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: "free-write", text: "too late" }), + }), + ); + expect(post.status).toBe(409); + }); + + it("PATCH /sharing is also locked after share", async () => { + const { recipient } = await seed(); + await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/share`, { + method: "POST", + }), + ); + const patch = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/sharing`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "date", date: "2030-01-01" }), + }), + ); + expect(patch.status).toBe(409); + }); + + it("envelope reflects archived state after share", async () => { + const { recipient } = await seed(); + await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/share`, { + method: "POST", + }), + ); + const env = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}`), + ); + const body = (await env.json()) as { archived: boolean }; + expect(body.archived).toBe(true); + }); +}); + +dscribe("Recipient account + login", () => { + it("POST /r/:token/account sets credentials + envelope flips accountCreated", async () => { + const { recipient } = await seed(); + const email = procEmail("mom-login-a"); + const post = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/account`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: email.toUpperCase(), + password: "the-radio-was-on", + }), + }), + ); + expect(post.status).toBe(200); + const body = (await post.json()) as { + recipient: { email: string; accountCreated: boolean }; + }; + // Email is normalized. + expect(body.recipient.email).toBe(email); + expect(body.recipient.accountCreated).toBe(true); + + const env = await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}`), + ); + const envBody = (await env.json()) as { + recipient: { accountCreated: boolean }; + }; + expect(envBody.recipient.accountCreated).toBe(true); + }); + + it("POST /auth/recipient/login returns the access token + redirect", async () => { + const { recipient } = await seed(); + const email = procEmail("mom-login-b"); + await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/account`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email, + password: "the-radio-was-on", + }), + }), + ); + const login = await buildApp().handle( + new Request("http://localhost/auth/recipient/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email, + password: "the-radio-was-on", + }), + }), + ); + expect(login.status).toBe(200); + const body = (await login.json()) as { + token: string; + redirectTo: string; + }; + expect(body.token).toBe(recipient.accessToken); + expect(body.redirectTo).toBe(`/r/${recipient.accessToken}/journal`); + }); + + it("POST /auth/recipient/login rejects wrong password", async () => { + const { recipient } = await seed(); + const email = procEmail("mom-login-c"); + await buildApp().handle( + new Request(`http://localhost/r/${recipient.accessToken}/account`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email, + password: "right-password", + }), + }), + ); + const login = await buildApp().handle( + new Request("http://localhost/auth/recipient/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email, + password: "wrong-password", + }), + }), + ); + expect(login.status).toBe(401); + }); + + it("POST /auth/recipient/login rejects unknown email", async () => { + const login = await buildApp().handle( + new Request("http://localhost/auth/recipient/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: procEmail("nobody"), + password: "anything", + }), + }), + ); + expect(login.status).toBe(401); + }); +}); + +dscribe("Bad token handling", () => { + it("entries POST returns 404 for unknown token", async () => { + const res = await buildApp().handle( + new Request("http://localhost/r/bogus/entries", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: "free-write", text: "ignored" }), + }), + ); + expect(res.status).toBe(404); + }); + + it("sharing GET returns 404 for unknown token", async () => { + const res = await buildApp().handle( + new Request("http://localhost/r/bogus/sharing"), + ); + expect(res.status).toBe(404); + }); +}); diff --git a/server/src/routes/recipient.integration.test.ts b/server/src/routes/recipient.integration.test.ts index acfe2ed..b5f60b0 100644 --- a/server/src/routes/recipient.integration.test.ts +++ b/server/src/routes/recipient.integration.test.ts @@ -88,6 +88,10 @@ async function seed() { } beforeAll(() => { + // Skip when not running integration — `process.env` is process-global, + // so leaving TEST_USER_EMAIL set here would short-circuit + // authContext.maybeTestUser() in every other test file's request. + if (!SHOULD_RUN) return; process.env.TEST_USER_EMAIL = TEST_EMAIL; process.env.OPENAI_API_KEY = "sk-test-stub"; }); diff --git a/server/src/routes/recipient.ts b/server/src/routes/recipient.ts index e04ec96..3f9989c 100644 --- a/server/src/routes/recipient.ts +++ b/server/src/routes/recipient.ts @@ -1,17 +1,34 @@ import { Elysia, t } from "elysia"; import { + createEntry, + deleteEntry, deleteResponse, + type EntrySource, + getEntry, + getSharing, + isJournalArchived, + listEntries, listQuestions, listResponses, listResponsesForQuestion, loadByToken, + patchEntry, + patchSharing, + performShare, + type Question, + recipientHasAccount, saveResponse, + setRecipientCredentials, + SharingLockedError, touchRecipient, } from "../lib/gift-store"; import { errorSchema, + journalEntrySchema, + parentPromptSchema, questionSchema, responseSchema, + sharingStateSchema, } from "../lib/route-helpers"; import { bucketFor, getStorage, keyFor } from "../lib/storage"; import { @@ -24,6 +41,24 @@ const giverSchema = t.Object({ recipientName: t.String(), }); +const tokenEnvelopeSchema = t.Object({ + giver: giverSchema, + recipient: t.Object({ + id: t.String(), + name: t.String(), + email: t.String(), + accountCreated: t.Boolean(), + }), + personalMessage: t.Union([t.String(), t.Null()]), + prompts: t.Array(parentPromptSchema), + sharing: sharingStateSchema, + archived: t.Boolean(), + // Kept for backwards compat with the existing recipient page that + // reads `questions` + `responses` (legacy per-question flow). + questions: t.Array(questionSchema), + responses: t.Array(responseSchema), +}); + function extFromImageMime(mime: string | undefined): string { if (!mime) return "bin"; if (mime.includes("png")) return "png"; @@ -34,7 +69,29 @@ function extFromImageMime(mime: string | undefined): string { return "bin"; } +function questionToPrompt(q: Question): { + id: string; + text: string; + preface: string | null; + photoUrl: string | null; +} { + return { + id: q.id, + text: q.text, + preface: q.preface, + photoUrl: q.photoUrl, + }; +} + export const recipientRoutes = new Elysia({ prefix: "/r" }) + // --------------------------------------------------------------- + // Token envelope: everything the parent's home/letter/start needs. + // Returns 404 on bad token, otherwise a single `{ giver, recipient, + // personalMessage, prompts, sharing, archived, questions, responses }` + // blob. The legacy `questions` + `responses` keys remain for the + // per-question flow that pre-existed this branch — the new parent + // UI consumes `prompts`, `entries`, and `sharing` instead. + // --------------------------------------------------------------- .get( "/:token", async ({ params, set }) => { @@ -43,33 +100,599 @@ export const recipientRoutes = new Elysia({ prefix: "/r" }) set.status = 404; return { error: "Invalid or expired link" }; } - const [, questions, responses] = await Promise.all([ + const [, questions, responses, sharing] = await Promise.all([ touchRecipient(params.token), listQuestions(found.gift.id), listResponses(found.gift.id), + getSharing(found.recipient.id), ]); return { giver: { recipientName: found.gift.recipientName }, + recipient: { + id: found.recipient.id, + name: found.recipient.name, + email: found.recipient.email, + accountCreated: Boolean(found.recipient.accountCreatedAt), + }, + personalMessage: found.gift.personalMessage, + prompts: questions.map(questionToPrompt), + sharing, + archived: Boolean(sharing.sharedAt), questions, responses, }; }, { params: t.Object({ token: t.String() }), + response: { + 200: tokenEnvelopeSchema, + 404: errorSchema, + }, + detail: { + summary: "Recipient envelope (giver, prompts, sharing, archived)", + tags: ["Recipient"], + }, + }, + ) + // --------------------------------------------------------------- + // Parent prompts — same set as the giver's curated questions but + // shaped for the parent's prompt picker. "Used" is derived + // client-side by matching JournalEntry.promptId against `id`. + // --------------------------------------------------------------- + .get( + "/:token/prompts", + async ({ params, set }) => { + const found = await loadByToken(params.token); + if (!found) { + set.status = 404; + return { error: "Invalid or expired link" }; + } + const questions = await listQuestions(found.gift.id); + return { prompts: questions.map(questionToPrompt) }; + }, + { + params: t.Object({ token: t.String() }), + response: { + 200: t.Object({ prompts: t.Array(parentPromptSchema) }), + 404: errorSchema, + }, + detail: { + summary: "Prompts the giver curated, exposed to the recipient", + tags: ["Recipient"], + }, + }, + ) + // --------------------------------------------------------------- + // Journal entries CRUD (parent flow). + // --------------------------------------------------------------- + .get( + "/:token/entries", + async ({ params, set }) => { + const found = await loadByToken(params.token); + if (!found) { + set.status = 404; + return { error: "Invalid or expired link" }; + } + const entries = await listEntries(found.recipient.id); + return { entries }; + }, + { + params: t.Object({ token: t.String() }), + response: { + 200: t.Object({ entries: t.Array(journalEntrySchema) }), + 404: errorSchema, + }, + detail: { summary: "List journal entries", tags: ["Recipient"] }, + }, + ) + .get( + "/:token/entries/:eid", + async ({ params, set }) => { + const found = await loadByToken(params.token); + if (!found) { + set.status = 404; + return { error: "Invalid or expired link" }; + } + const entry = await getEntry(found.recipient.id, params.eid); + if (!entry) { + set.status = 404; + return { error: "Entry not found" }; + } + return { entry }; + }, + { + params: t.Object({ token: t.String(), eid: t.String() }), + response: { + 200: t.Object({ entry: journalEntrySchema }), + 404: errorSchema, + }, + detail: { summary: "Get a journal entry", tags: ["Recipient"] }, + }, + ) + .post( + "/:token/entries", + async ({ params, body, set }) => { + const found = await loadByToken(params.token); + if (!found) { + set.status = 404; + return { error: "Invalid or expired link" }; + } + if (await isJournalArchived(found.recipient.id)) { + set.status = 409; + return { error: "Journal is shared and locked" }; + } + + let promptText = body.promptText ?? null; + // Snapshot the prompt text at write time so subsequent giver + // edits don't change what the parent saw when journaling. + if (body.promptId && !promptText) { + const questions = await listQuestions(found.gift.id); + const q = questions.find((x) => x.id === body.promptId); + if (q) promptText = q.text; + } + + const entry = await createEntry(found.gift.id, found.recipient.id, { + source: body.source as EntrySource, + text: body.text ?? null, + promptId: body.promptId ?? null, + promptText, + preface: body.preface ?? null, + }); + await touchRecipient(params.token); + return { entry }; + }, + { + params: t.Object({ token: t.String() }), + body: t.Object({ + source: t.Union([ + t.Literal("free-write"), + t.Literal("prompt"), + t.Literal("ai"), + t.Literal("voice"), + t.Literal("photo"), + ]), + text: t.Optional(t.String({ maxLength: 16000 })), + promptId: t.Optional(t.String()), + promptText: t.Optional(t.String({ maxLength: 2000 })), + preface: t.Optional(t.String({ maxLength: 2000 })), + }), + response: { + 200: t.Object({ entry: journalEntrySchema }), + 404: errorSchema, + 409: errorSchema, + }, + detail: { summary: "Create a journal entry", tags: ["Recipient"] }, + }, + ) + .patch( + "/:token/entries/:eid", + async ({ params, body, set }) => { + const found = await loadByToken(params.token); + if (!found) { + set.status = 404; + return { error: "Invalid or expired link" }; + } + if (await isJournalArchived(found.recipient.id)) { + set.status = 409; + return { error: "Journal is shared and locked" }; + } + const entry = await patchEntry(found.recipient.id, params.eid, body); + if (!entry) { + set.status = 404; + return { error: "Entry not found" }; + } + return { entry }; + }, + { + params: t.Object({ token: t.String(), eid: t.String() }), + body: t.Object({ + text: t.Optional(t.String({ maxLength: 16000 })), + promptText: t.Optional(t.String({ maxLength: 2000 })), + preface: t.Optional(t.String({ maxLength: 2000 })), + durationSeconds: t.Optional(t.Number()), + }), + response: { + 200: t.Object({ entry: journalEntrySchema }), + 404: errorSchema, + 409: errorSchema, + }, + detail: { summary: "Patch a journal entry", tags: ["Recipient"] }, + }, + ) + .delete( + "/:token/entries/:eid", + async ({ params, set }) => { + const found = await loadByToken(params.token); + if (!found) { + set.status = 404; + return { error: "Invalid or expired link" }; + } + if (await isJournalArchived(found.recipient.id)) { + set.status = 409; + return { error: "Journal is shared and locked" }; + } + const ok = await deleteEntry(found.recipient.id, params.eid); + if (!ok) { + set.status = 404; + return { error: "Entry not found" }; + } + return { ok: true as const }; + }, + { + params: t.Object({ token: t.String(), eid: t.String() }), + response: { + 200: t.Object({ ok: t.Literal(true) }), + 404: errorSchema, + 409: errorSchema, + }, + detail: { summary: "Delete a journal entry", tags: ["Recipient"] }, + }, + ) + .post( + "/:token/entries/:eid/photo", + async ({ params, body, set }) => { + const found = await loadByToken(params.token); + if (!found) { + set.status = 404; + return { error: "Invalid or expired link" }; + } + if (await isJournalArchived(found.recipient.id)) { + set.status = 409; + return { error: "Journal is shared and locked" }; + } + const existing = await getEntry(found.recipient.id, params.eid); + if (!existing) { + set.status = 404; + return { error: "Entry not found" }; + } + const file = body.photo as File; + if (!file || file.size === 0) { + set.status = 400; + return { error: "No photo file provided" }; + } + + const ext = extFromImageMime(file.type); + const storage = await getStorage(); + const buf = await file.arrayBuffer(); + const put = await storage.put({ + bucket: bucketFor("r-photos"), + key: keyFor("r-photos", { + giftId: found.gift.id, + questionId: existing.id, + ext, + }), + body: buf, + contentType: file.type || "application/octet-stream", + }); + const updated = await patchEntry(found.recipient.id, existing.id, { + photoUrl: put.url, + }); + await touchRecipient(params.token); + // patchEntry may return null only if the row was deleted between + // the load above and this update — extremely unlikely, treat as 404. + if (!updated) { + set.status = 404; + return { error: "Entry not found" }; + } + return { entry: updated, photoUrl: put.url }; + }, + { + params: t.Object({ token: t.String(), eid: t.String() }), + body: t.Object({ photo: t.File({ maxSize: "10m" }) }), response: { 200: t.Object({ - giver: giverSchema, - questions: t.Array(questionSchema), - responses: t.Array(responseSchema), + entry: journalEntrySchema, + photoUrl: t.String(), + }), + 400: errorSchema, + 404: errorSchema, + 409: errorSchema, + }, + detail: { + summary: "Upload a photo for a journal entry", + tags: ["Recipient"], + }, + }, + ) + .post( + "/:token/entries/:eid/audio", + async ({ params, body, set }) => { + const found = await loadByToken(params.token); + if (!found) { + set.status = 404; + return { error: "Invalid or expired link" }; + } + if (await isJournalArchived(found.recipient.id)) { + set.status = 409; + return { error: "Journal is shared and locked" }; + } + const existing = await getEntry(found.recipient.id, params.eid); + if (!existing) { + set.status = 404; + return { error: "Entry not found" }; + } + const file = body.audio as File; + if (!file || file.size === 0) { + set.status = 400; + return { error: "No audio file provided" }; + } + + // 1. Persist raw audio first — the voice IS the product + // (BACKEND_HANDOFF_V2 §6). If transcription later fails, we still + // have the recording. + const ext = extFromMime(file.type); + const storage = await getStorage(); + const buf = await file.arrayBuffer(); + const put = await storage.put({ + bucket: bucketFor("r-audio"), + key: keyFor("r-audio", { + giftId: found.gift.id, + questionId: existing.id, + ext, + }), + body: buf, + contentType: file.type || "audio/webm", + }); + + // 2. Transcribe — best-effort. The recipient's text field is + // either the transcript or whatever they had before. + let transcript = existing.text ?? ""; + try { + const filename = file.name || `audio.${ext}`; + const result = await transcribeAudio(file, filename); + if (result.text) transcript = result.text; + } catch (err) { + if (err instanceof TranscribeError) { + console.error( + "[recipient/entries/audio] transcribe failed:", + err.status, + err.detail ?? err.message, + ); + } else { + console.error("[recipient/entries/audio] transcribe error:", err); + } + } + + const durationSeconds = + typeof body.durationSeconds === "number" + ? Math.max(0, Math.round(body.durationSeconds)) + : undefined; + + const updated = await patchEntry(found.recipient.id, existing.id, { + audioUrl: put.url, + text: transcript, + durationSeconds, + }); + await touchRecipient(params.token); + if (!updated) { + set.status = 404; + return { error: "Entry not found" }; + } + return { + entry: updated, + audioUrl: put.url, + transcript, + durationSeconds: updated.durationSeconds ?? null, + }; + }, + { + params: t.Object({ token: t.String(), eid: t.String() }), + // Multipart bodies arrive as strings — t.Numeric() coerces. + body: t.Object({ + audio: t.File({ maxSize: "25m" }), + durationSeconds: t.Optional(t.Numeric()), + }), + response: { + 200: t.Object({ + entry: journalEntrySchema, + audioUrl: t.String(), + transcript: t.String(), + durationSeconds: t.Union([t.Number(), t.Null()]), + }), + 400: errorSchema, + 404: errorSchema, + 409: errorSchema, + }, + detail: { + summary: "Upload audio for a journal entry (with transcription)", + tags: ["Recipient"], + }, + }, + ) + // --------------------------------------------------------------- + // Sharing config + one-time share trigger. + // --------------------------------------------------------------- + .get( + "/:token/sharing", + async ({ params, set }) => { + const found = await loadByToken(params.token); + if (!found) { + set.status = 404; + return { error: "Invalid or expired link" }; + } + const sharing = await getSharing(found.recipient.id); + return { sharing }; + }, + { + params: t.Object({ token: t.String() }), + response: { + 200: t.Object({ sharing: sharingStateSchema }), + 404: errorSchema, + }, + detail: { summary: "Read sharing state", tags: ["Recipient"] }, + }, + ) + .patch( + "/:token/sharing", + async ({ params, body, set }) => { + const found = await loadByToken(params.token); + if (!found) { + set.status = 404; + return { error: "Invalid or expired link" }; + } + try { + const sharing = await patchSharing(found.recipient.id, body); + return { sharing }; + } catch (err) { + if (err instanceof SharingLockedError) { + set.status = 409; + return { error: err.message }; + } + throw err; + } + }, + { + params: t.Object({ token: t.String() }), + body: t.Object({ + mode: t.Optional( + t.Union([ + t.Literal("when-ready"), + t.Literal("legacy"), + t.Literal("date"), + t.Literal("milestone"), + ]), + ), + date: t.Optional(t.Union([t.String(), t.Null()])), + milestonePreset: t.Optional( + t.Union([ + t.Literal("future-birthday"), + t.Literal("anniversary"), + t.Literal("in-one-year"), + t.Literal("custom"), + t.Null(), + ]), + ), + milestoneText: t.Optional(t.Union([t.String({ maxLength: 200 }), t.Null()])), + }), + response: { + 200: t.Object({ sharing: sharingStateSchema }), + 404: errorSchema, + 409: errorSchema, + }, + detail: { + summary: "Update sharing config (one-time before share)", + tags: ["Recipient"], + }, + }, + ) + .post( + "/:token/share", + async ({ params, set }) => { + const found = await loadByToken(params.token); + if (!found) { + set.status = 404; + return { error: "Invalid or expired link" }; + } + const result = await performShare(found.recipient.id); + return { + sharing: result.sharing, + alreadyShared: result.alreadyShared, + }; + }, + { + params: t.Object({ token: t.String() }), + response: { + 200: t.Object({ + sharing: sharingStateSchema, + alreadyShared: t.Boolean(), + }), + 404: errorSchema, + }, + detail: { + summary: "Perform the one-time share (idempotent)", + tags: ["Recipient"], + }, + }, + ) + // --------------------------------------------------------------- + // Recipient account creation (called on /r/:token/account). + // --------------------------------------------------------------- + .post( + "/:token/account", + async ({ params, body, set }) => { + const found = await loadByToken(params.token); + if (!found) { + set.status = 404; + return { error: "Invalid or expired link" }; + } + const recipient = await setRecipientCredentials( + params.token, + body.email, + body.password, + ); + if (!recipient) { + set.status = 500; + return { error: "Failed to save credentials" }; + } + return { + ok: true as const, + recipient: { + id: recipient.id, + name: recipient.name, + email: recipient.email, + accountCreated: Boolean(recipient.accountCreatedAt), + }, + }; + }, + { + params: t.Object({ token: t.String() }), + body: t.Object({ + email: t.String({ minLength: 3, maxLength: 320 }), + password: t.String({ minLength: 6, maxLength: 256 }), + }), + response: { + 200: t.Object({ + ok: t.Literal(true), + recipient: t.Object({ + id: t.String(), + name: t.String(), + email: t.String(), + accountCreated: t.Boolean(), + }), + }), + 404: errorSchema, + 500: errorSchema, + }, + detail: { + summary: "Set recipient login credentials", + tags: ["Recipient"], + }, + }, + ) + .get( + "/:token/account", + async ({ params, set }) => { + const found = await loadByToken(params.token); + if (!found) { + set.status = 404; + return { error: "Invalid or expired link" }; + } + return { + accountCreated: await recipientHasAccount(params.token), + email: found.recipient.email, + }; + }, + { + params: t.Object({ token: t.String() }), + response: { + 200: t.Object({ + accountCreated: t.Boolean(), + email: t.String(), }), 404: errorSchema, }, detail: { - summary: "Recipient view of a gift (token auth)", + summary: "Check if recipient has set up a login", tags: ["Recipient"], }, }, ) + // --------------------------------------------------------------- + // Legacy per-question response endpoints. These pre-existed this + // branch and stay in place; the new parent UI uses + // /entries instead. Keeping them avoids churn for any caller that + // still hits the /questions/:qid surface. + // --------------------------------------------------------------- .get( "/:token/questions/:qid", async ({ params, set }) => { @@ -135,7 +758,7 @@ export const recipientRoutes = new Elysia({ prefix: "/r" }) 200: t.Object({ response: responseSchema }), 404: errorSchema, }, - detail: { summary: "Save a text answer", tags: ["Recipient"] }, + detail: { summary: "Save a text answer (legacy)", tags: ["Recipient"] }, }, ) .post( @@ -158,8 +781,6 @@ export const recipientRoutes = new Elysia({ prefix: "/r" }) return { error: "No audio file provided" }; } - // 1. Persist raw audio. Audio stays raw — see BACKEND_HANDOFF §6: - // the voice in the parent's voice is the product. const ext = extFromMime(file.type); const storage = await getStorage(); const buf = await file.arrayBuffer(); @@ -174,9 +795,6 @@ export const recipientRoutes = new Elysia({ prefix: "/r" }) contentType: file.type || "audio/webm", }); - // 2. Transcribe (Whisper). Failure shouldn't drop the audio — - // the recipient already submitted, the transcript is a - // nice-to-have for preview/search/accessibility. let transcript = ""; try { const filename = file.name || `audio.${ext}`; @@ -215,7 +833,7 @@ export const recipientRoutes = new Elysia({ prefix: "/r" }) 400: errorSchema, 404: errorSchema, }, - detail: { summary: "Save a voice answer", tags: ["Recipient"] }, + detail: { summary: "Save a voice answer (legacy)", tags: ["Recipient"] }, }, ) .post( @@ -281,7 +899,7 @@ export const recipientRoutes = new Elysia({ prefix: "/r" }) 400: errorSchema, 404: errorSchema, }, - detail: { summary: "Save a photo answer", tags: ["Recipient"] }, + detail: { summary: "Save a photo answer (legacy)", tags: ["Recipient"] }, }, ) .delete( @@ -308,6 +926,6 @@ export const recipientRoutes = new Elysia({ prefix: "/r" }) 200: t.Object({ ok: t.Literal(true) }), 404: errorSchema, }, - detail: { summary: "Delete a response", tags: ["Recipient"] }, + detail: { summary: "Delete a response (legacy)", tags: ["Recipient"] }, }, ); diff --git a/web/app/oauth/route.ts b/web/app/oauth/route.ts index 77c2f2f..9575637 100644 --- a/web/app/oauth/route.ts +++ b/web/app/oauth/route.ts @@ -44,7 +44,7 @@ export async function GET(request: NextRequest): Promise { if (!sessionKey) return loginRedirect("oauth_empty_key") // Same-app paths only — open-redirect guard. - const baseDest = nextHint && nextHint.startsWith("/") ? nextHint : "/" + const baseDest = nextHint?.startsWith("/") ? nextHint : "/" const destUrl = new URL(baseDest, baseUrl) destUrl.searchParams.set("signed_in", "google") const res = NextResponse.redirect(destUrl) diff --git a/web/app/onboarding/questions/review/page.tsx b/web/app/onboarding/questions/review/page.tsx index 36c14ac..2043af9 100644 --- a/web/app/onboarding/questions/review/page.tsx +++ b/web/app/onboarding/questions/review/page.tsx @@ -197,7 +197,6 @@ export default function ReviewQuestionsPage() {