Accounts and Roles¶
The app is growing from a single-device experience into a small family system with two kinds of people:
- Senior (programmatic role:
user): the person taking quizzes and revisiting memories. A typical end user, on the phone app. - Loved One (programmatic role:
admin): a family member who curates content: photos, videos, questions. Uses a website, and there can be one or many per senior.
This page is the architecture plan, the parts that exist today, and how to run them.
Role names: stable codes, swappable labels¶
Databases, APIs, and code only ever use the codes user and admin.
The names shown on screen ("Senior", "Loved One") live in exactly one
place, packages/shared/src/roles.ts:
export const ROLES = { user: 'user', admin: 'admin' } as const;
export type Role = keyof typeof ROLES;
export const roleLabels: Record<Role, string> = {
user: 'Senior',
admin: 'Loved One',
};
Every surface (mobile app, web app) imports labels from there. Renaming "Loved One" later is a one-line change that touches no logic, no stored data, and no API contracts.
Data model¶
The key idea is separating who someone is from what they can access:
- The account role (
useroradmin) decides which experience someone gets: the senior gets the simple quiz app, the Loved One gets authoring tools. - Memory banks decide whose content they can touch. Each senior owns exactly one bank: their unique memories. Loved Ones and (later) guests reach a bank through a membership row, never by role alone.
Tables (D1, migrations in apps/api/migrations/):
| Table | What it holds |
|---|---|
users |
id, name, email, password hash, role (user or admin) |
memory_banks |
one per senior; senior_user_id points at the owner |
bank_members |
who can reach a bank: access is manage (Loved One) or view |
share_codes |
future: short codes that grant view access to a bank |
media_assets |
photos and videos in a bank; description is NOT NULL by design |
sessions |
hashed bearer tokens with expiry |
A Loved One can manage several banks (Grandma and Grandpa), and a senior
can have many Loved Ones. The future "send a code to view my memories"
feature is one insert into bank_members with access = 'view'.
The Born Accessible rule from the app's media catalog carries over to the
database itself: media_assets.description is NOT NULL with a
non-empty check, so an undescribed photo cannot exist, let alone be
shown.
Where things run (Cloudflare)¶
| Piece | Where |
|---|---|
| Data-plane API (this plan) | Cloudflare Worker (apps/api), Hono + D1 |
| Media file storage | R2 (local simulator during development) |
| Loved One web app | apps/web (Vite + React), Cloudflare Pages |
| Media processing | Existing FastAPI backend, unchanged: Pillow, |
| ffmpeg, Deepgram. Stateless, no database. | |
| Docs | Cloudflare Pages (already live) |
D1 is designed to be queried from Workers, which is why the data plane is
a TypeScript Worker rather than the Python backend. The Worker shares
packages/shared with the mobile and web apps, so role codes and API
types are one set of definitions everywhere. FastAPI keeps its narrow,
stateless job and needs no auth of its own beyond a service token once
the Worker starts calling it.
Auth, shaped by who is logging in¶
- Loved One: email and password on the web app. Passwords are hashed with PBKDF2 (WebCrypto, 100k iterations); sessions are random bearer tokens stored hashed in D1 with a 30-day expiry.
- Senior: never types a password. The plan is device pairing: a Loved One generates a short one-time code on the web, it is entered once on the senior's phone, and the phone keeps a long-lived token in secure storage. After that the app just opens.
Dev auto-login¶
During early development nobody wants a sign-in flow on every test run:
- The dev seed creates a fixed senior with a well-known token, and the
mobile app short-circuits straight to a session when
EXPO_PUBLIC_DEV_AUTO_LOGINholds that token (set it inapps/mobile/.env). That variable is never set in the EAS preview or production profiles, so a release build cannot ship auto-logged-in by construction. - The seed also creates a dev Loved One for the web app (credentials below).
- The seed file is applied by hand (
pnpm seed:api), never by production migrations, so the well-known credentials only exist in a local database.
What exists today¶
A Loved One can sign in on the web app and curate the senior's bank across three sections: Photos, Videos, and Quiz.
packages/shared: role codes, display labels, topics, and the API types.apps/api: the Worker. Auth (POST /auth/login,GET /auth/me,POST /auth/logout),GET /banks, and per-bank content routes that all require a bearer token and membership in the bank:/banks/:bankId/photosand/banks/:bankId/videos: list, multipart upload (description required; for a video it doubles as the audio description), serve the file bytes, delete. One route factory serves both kinds (src/routes/media.ts)./banks/:bankId/questions: list, create, delete, plusPOST .../suggest-wrong-answers, which returns three random plausible wrong options for the topic from curated, era-appropriate pools (src/distractors.ts). That function is the single seam where a Claude-generated version can slot in later without changing the route or the web app. Question choices are shuffled server-side and use the same kebab-case ids and formats (multiple-choice,this-or-that) as the mobile seed content.apps/web: the Loved One app. Sign in, switch between the three sections, upload photos and videos with required descriptions, author quiz questions with a "Suggest wrong answers" button that fills the three editable wrong-answer boxes. Photo thumbnails use the description as alt text; videos load on request so opening the section does not download every file.-
Story wizard and question sets: in the Quiz section, "Build questions from a story" takes pasted text (an interview, a letter, a written-down story) and generates question candidates from it (
apps/api/src/question-generator.ts, pattern-based: places, jobs, cars, years, and fill-in-the-blank memories; the same swap-in-Claude-later seam as the distractors). The wizard shows five candidates; the Loved One taps at least one, then gets five fresh candidates ranked toward the topics and styles of the first picks and takes as many as they want. The batch saves as a question set named after the current date and time, renameable from the wizard or the sets list. Wrong answers are filled automatically at save, and a year answer gets nearby years rather than pool words. Endpoints:POST /banks/:bankId/question-sets/generate, then CRUD plusPOST .../:setId/renameunder/banks/:bankId/question-sets; a set and its questions save in one D1 transaction. -
Stories on the phone plays bank content: the mobile app resolves a session (dev auto-login below), fetches the senior's bank questions and photos from the Worker, and merges them with the bundled seed before each session draw (
apps/mobile/src/features/bank/,src/features/stories/use-story-pool.ts). Photos arrive as reminiscence moments (the photo above its title, a Continue button, no grading), which the session sorter already places at the end, so a session closes on warmth. Image requests carry the session's auth header, attached at runtime and never cached, so the offline cache holds no credentials. The last successful fetch is cached on the device; an unreachable Worker falls back to cache within a few seconds. Every fetched row is validated field by field; a malformed row is dropped, never shown. - Removing an authored question: the web app has Remove on each question and Delete on each set, and the phone has Settings > Memory bank > Manage Quiz Questions, a confirm-then-delete list of the bank's questions (the senior owns the bank, so the phone's session has manage access). Removal deletes for everyone; the bundled starter questions are part of the app and are not listed.
Not yet built: real device pairing (dev auto-login only), videos in the senior's UI, media attached to questions, answer records synced back for Loved Ones, share codes, and cloud deployment.
Running it locally¶
pnpm install
pnpm dev:api # Worker on http://localhost:8787 (wrangler dev, local D1 + R2)
pnpm dev:web # Loved One app on http://localhost:5173
The first pnpm dev:api run needs the database created and seeded:
pnpm --filter @senior-health/api migrate # apply migrations to local D1
pnpm seed:api # create dev users and the bank
Dev credentials (local database only):
- Loved One:
loved.one@example.com/daybreak-dev(manages every bank) - Tom's device token:
dev-tom-token - Nyna's device token:
dev-nyna-token - Thomas's device token:
dev-thomas-token
The two seeded seniors mirror the mobile roster
(src/features/seniors/roster.ts), so the senior switch in the app's
Settings moves between real banks; see
Multiple seniors.
Rollout phases¶
- Foundations (done): shared roles package, Worker + D1 schema, auth, photo upload, Loved One web app.
- Mobile (content done): the dev auto-login short-circuit, Stories questions and photos fetched from the Worker with an offline cache, and question removal from the phone are in. Still to come in this phase: answer records synced back so Loved Ones can see activity.
- Web authoring: question and quiz authoring, video upload through the FastAPI processors, progress views, pairing-code generation.
- Share codes: read-only viewing of another senior's memories, plus cloud deployment (D1 + R2 + Pages) once the flows settle.