Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,7 @@ yarn-error.log*
next-env.d.ts

# elysia
server/dist
server/dist

# claude code worktree + runtime artifacts
.claude/
723 changes: 723 additions & 0 deletions BACKEND_HANDOFF_V2.md

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@
},
"a11y": {
"useValidAnchor": "warn",
"useButtonType": "warn"
"useButtonType": "warn",
"noSvgWithoutTitle": "off",
"noRedundantAlt": "warn"
},
"performance": {
"noImgElement": "off"
}
}
},
Expand Down
63 changes: 63 additions & 0 deletions server/src/db/migrations/004_recipient_flow.sql
Original file line number Diff line number Diff line change
@@ -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;
67 changes: 67 additions & 0 deletions server/src/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
date,
index,
integer,
pgEnum,
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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 }),
Expand Down Expand Up @@ -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),
],
);

Expand Down Expand Up @@ -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"),
});
Loading
Loading