Custom web app for Lanna Care for Animals, a not-for-profit animal shelter, replacing their current AppSheet/Google Sheets system.
Обзор
Custom web app for Lanna Care for Animals, a not-for-profit animal shelter, replacing their current AppSheet/Google Sheets system. Full requirements: docs/requirements/lanna-care-rebuild-requirements.md. Decisions and open questions: docs/decisions/ (one file per decision, from 2026-09-24) and the earlier record in docs/decisions.md. - (App Router) + - (Postgres + Auth + RLS) — see supabase/migrations/ - for file/photo storage (see docs/decisions.md), except facility plan images, which live in a private Supabase Storage bucket (src/lib/facility-map/plan-store.ts) - for resident profile / deceased summary PDFs - Hosting: a (free tier, via @opennextjs/cloudflare) fronts the site — edge cache for the public pages, then the origin through a Cloudflare Tunnel, then rendering in the Worker itself as the fallback. See docs/pi-hosting.md. Everything is chosen to run on free tiers — see docs/decisions.md for the reasoning and the trade-offs that come with that.
README
Lanna Care for Animals
Custom web app for Lanna Care for Animals, a not-for-profit animal shelter, replacing their current AppSheet/Google Sheets system.
Full requirements: docs/requirements/lanna-care-rebuild-requirements.md.
Decisions and open questions: docs/decisions/ (one file per decision, from 2026-09-24) and the earlier record in docs/decisions.md.
Stack
- Next.js (App Router) + Tailwind CSS
- Supabase (Postgres + Auth + RLS) — see
supabase/migrations/ - Google Drive API for file/photo storage (see
docs/decisions.md), except facility plan images, which live in a private Supabase Storage bucket (src/lib/facility-map/plan-store.ts) - @react-pdf/renderer for resident profile / deceased summary PDFs
- Hosting: a Cloudflare Worker (free tier, via
@opennextjs/cloudflare) fronts the site — edge cache for the public pages, then the Raspberry Pi origin through a Cloudflare Tunnel, then rendering in the Worker itself as the fallback. Seedocs/pi-hosting.md.
Everything is chosen to run on free tiers — see docs/decisions.md for the
reasoning and the trade-offs that come with that.
Roles
A role is a row of the roles table (0132), not a database enum: 0181 dropped app_role, user_roles.role and current_user_role(). A login holds one role through user_roles.role_id, assigned at /admin/security, and each role is granted a set of activities in role_permissions. Row-level security policies and app pages both ask has_permission() (src/lib/permissions/) rather than naming a role. The live roles are admin, management, second_in_command, head_of_maintenance, head_of_medical, doctor, volunteer and public_viewer; staff was retired on 2026-10-09 (0173, docs/decisions/2026-10-09-staff-role-removed.md). A doctor belongs to a clinic and sees that clinic’s records only (RLS since 0167/0178).
What each role can do today is Settings → Who does what (/admin/who-does-what, Admin and Management): every activity × every live role, read live from role_permissions, with each box the system does not yet obey marked from src/lib/permissions/fixed-for-now.ts. That list is measured, not written: node scripts/check-who-does-what.mjs (dev) runs every box under each role, as it is and flipped, and fails when the list is wrong or stale; node scripts/check-who-does-what-page.mjs checks the page against the database (docs/decisions/2026-10-11-who-does-what-view.md). The plain-language version is docs/roles-and-permissions.md.
A person who leaves is archived from /admin/security rather than deleted (user_roles.archived_at, 0063): every permission check answers no for them, so every policy and page treats them as having no access, while their name stays on the maintenance jobs they did. Restore reverses it. Archived and role-less logins are refused at sign-in, by password or Google, and a session archived after it signed in is signed out at its next app page.
Settings → Security needs 2-step verification (docs/decisions.md, 2026-09-27). The page and every action behind it require a session that has passed Supabase Auth’s authenticator-app (TOTP) factor — aal2 in the JWT (src/lib/auth/two-step.ts). The first visit sets up the app at /admin/security/verify; after that it asks for a code once per sign-in. The actions check aal2 themselves, because they write with the service role, which bypasses the aal2 RLS policies on user_roles (0100). Nothing else in the app asks for it. A lost phone must not lock the shelter out, and there are no backup codes, so recovery is:
-
Another admin who has passed 2-step presses Reset in the 2-step column of the users table on Security. The person sets the app up again the next time they open Security.
-
Last resort, when no admin can pass 2-step (the only admin lost their phone): the developer removes the factor with the service role —
node scripts/bootstrap-admin.mjs --env production --reset-2step [email protected]It changes nothing but that login’s authenticator app, and runs whatever roles exist.
Keep at least two admins enrolled so route 1 is always available. scripts/check-two-step-session.mjs re-measures on dev what the sessions report.
Getting started
-
Install dependencies
npm install -
Set up environment variables
Copy
.env.exampleto.env.localand fill in:- A Supabase project’s URL/keys (create one free at supabase.com)
- Google OAuth client credentials + refresh token for the shelter’s
Drive account (see
src/lib/google/drive.tsfor how this is used).node --env-file=.env.local scripts/check-drive-token.mjssays whether they still work; on a deployed site the top of Settings (/admin) does
-
Apply the database schema
node scripts/apply-migrations.mjsApplies every file in
supabase/migrations/that the project hasn’t seen yet, in order, and records each one in aschema_migrationstable there.--statusshows what’s applied and pending;--dry-runruns pending files inside a rolled-back transaction. NeedsSUPABASE_ACCESS_TOKEN(a personal access token from the Supabase dashboard) in.env.local; the target project comes fromNEXT_PUBLIC_SUPABASE_URL.A migration that creates a table, view or sequence must
grantit in the same file (authenticated, service_roleby default,anononly for deliberately public objects). From 2026-10-30 Supabase no longer adds those grants automatically.npm run lintfails a migration that leaves them out (scripts/check-migration-grants.mjs; seedocs/decisions.md, 2026-09-24). -
Run the dev server
npm run devOpen http://localhost:3000.
From a phone on the same Wi-Fi:
next devlistens on every interface, sohttp://:3000works too (ipconfig→ IPv4 Address).next.config.tsalready allows dev assets for192.168.1.*; for Google sign-in to come back to the phone the dev Supabase project’s Redirect URLs (Authentication → URL Configuration) must also listhttp://192.168.1.*:3000/auth/callback, or Supabase bounces the sign-in to its Site URL (localhost). -
Enable the git hooks (once per clone)
git config core.hooksPath .githooks.githooks/post-commitpushes the current branch to GitHub after every commit, so the remote always matches the local checkout..githooks/pre-commitrefuses a commit that adds a migration not numbered one past the highest onorigin/main, and names the file to rename it to (scripts/check-migration-numbers.mjs, which CI runs too). It never fetches, and it lets the commit through with a note when it cannot give a verdict. -
Check out the backlog worktree (once per clone)
git worktree add ../Animal_Shelter_Backlog backlogbacklogis a permanent branch that only ever changesdocs/backlog.md. Having it in its own folder means an item can be added at any moment without touching whatever the main checkout is in the middle of; seeCLAUDE.md“The backlog branch” for how it is merged back. -
Feature worktrees (one per workstream)
node scripts/worktree.mjs newCreates
../Animal_Shelter_on branchclaude/fromorigin/main, copies.env.localin, runsnpm ciand picks the next free dev-server port (recorded in.port;node scripts/worktree.mjs devuses it). The main checkout stays onmain; each workstream gets its own folder, dev server and Claude session, so two or three features can be built at once.list,syncanddonecover the rest of a stream’s life — seeCLAUDE.md“Workstreams”. Google sign-in on a new port needshttp://localhost:/auth/callbackin the dev Supabase project’s Redirect URLs, once per port.
Environments
Four environments, three of them deployed. UAT exists in the code and
the tooling but not yet as a site: until the production domain cutover,
lannacare.org is served by the production block and --env uat refuses
to deploy. The UAT column is what the cutover makes true.
| Dev | Test | UAT | Production | |
|---|---|---|---|---|
| Where | next dev on your machine |
test.lannacare.org (served by the Pi from its own clone once ORIGIN_KEY and the WAF rule are in place, docs/pi-hosting.md) |
lannacare.org, www.lannacare.org — for good |
lannacare.org until the cutover, then lannacareforanimals.org |
| Worker | — | lanna-animal-care-test |
lanna-animal-care-uat (no routes until the cutover) |
lanna-animal-care |
| Database | dev Supabase project (qxkmhwybjggxvsfxsxbd) |
the same dev project | dbkodyyxxhtygxcxmfcu — today’s production project, demoted at the cutover |
dbkodyyxxhtygxcxmfcu today; a new project from the cutover |
| Google Drive | dev account | dev account | dev account (the current tree) | dev account, until the shelter’s own account exists |
| Values from | .env.local |
.env.local |
.env.deploy.uat over .env.local |
.env.deploy.production over .env.local |
| Deploy | — | npm run deploy:test |
npm run deploy:uat |
npm run deploy:prod |
| Looks | teal, striped strip and Dev badge in the header | teal, strip and Dev badge | orange, UAT badge | orange |
| Generated PDFs | DEV watermark |
DEV watermark |
UAT watermark |
none |
| Release mail | never | never | [UAT] |
[UAT] until the cutover, then [Production] |
| Public website | open | locked — sign-in landing page | locked | locked until the cutover, then open |
Dev and Test are recoloured (teal instead of orange, greenish surfaces, a
Dev badge beside the logo once signed in) so a tab on the dev database
is never mistaken for the live site. UAT is deliberately not recoloured —
the customer should be testing the real thing — and differs from
Production only by its UAT badge and the watermark on the archive PDF
(src/lib/archive/resident-summary-pdf.tsx), which lands in a Drive that
dev, Test and UAT share. src/lib/app-env.ts decides from the Supabase
project ref the build was made with — not from NODE_ENV, which is
production on Test too — and the layout sets ``, which
globals.css keys the colour tokens on. Its UAT_PROJECT_REF is empty
until the cutover, because the project that will be UAT is production’s
today; set early, it would badge the live site.
Each person can also pick a colour theme (account menu → Colours), which
redefines the same tokens and so overrides dev’s teal. That is why the
header’s teal-and-black strip and the badges use fixed colours no theme
touches: under any theme they still say Dev or UAT
(docs/decisions/2026-10-10-user-colour-themes.md). Check colours with
node scripts/check-theme-contrast.mjs after changing any.
Locked public website. "PUBLIC_SITE": "locked" on a Worker block in
wrangler.jsonc (test, UAT, and production until the cutover) closes the
public pages to anyone signed out: / shows a sign-in landing page, other
pages redirect to /login and come back after sign-in, and every response
is noindex. Only /login, /login/forgot, /auth/callback, /privacy
and robots.txt stay open. Signed-in staff see everything as usual.
src/lib/public-site.ts has the rest; docs/decisions.md (2026-09-25) has
why. To look at it locally, put PUBLIC_SITE=locked in
.env.development.local (gitignored) and restart next dev.
What the cutover changes here, in one commit: UAT_PROJECT_REF in
src/lib/app-env.ts and production’s label in src/app/releases/page.tsx;
the lannacare.org routes move from the production block of
wrangler.jsonc to uat, and production gets the new domain,
RELEASE_MAIL_ENV Production and a From on that domain, and loses
"PUBLIC_SITE": "locked", or the live site opens on a sign-in page
(scripts/deploy.mjs refuses --env production while the block is off
lannacare.org and still locked);
SITE_ORIGINS.production in scripts/deploy.mjs and HOST= in
scripts/pi/deploy-pi.sh follow. A host move that carries the staff app
with it also changes src/lib/site-hosts.ts (the one list behind
serverActions.allowedOrigins in next.config.ts and the upload routes’
same-origin.ts) and httpHostHeader in scripts/pi/cloudflared-config.yml;
miss either and every Server Action or upload is refused on the new host while
every page still loads. node scripts/check-site-hosts.mjs fails if the two
disagree. Outside the repo: today’s
.env.deploy.production becomes .env.deploy.uat, a new
.env.deploy.production holds the new project’s values, and
node scripts/deploy.mjs --env uat --secrets gives the UAT Worker its
secrets. The Pi has no lanna-care-uat service yet; setup.sh installs
only lanna-care.
Two databases, both on Supabase’s free tier (the org allows two). Test
deliberately shares the dev database: one developer, throwaway data, and
what Test is for is exercising a real Workers build of main on phones
before it goes to production. Production holds the shelter’s real records.
Both are free-tier projects, which means no automatic backups beyond
the daily scripts/backup.mjs dump — see “Backups” below. Free projects also pause after seven days with no
requests at all, which the public pages make impossible while the site is
up.
scripts/lib/env.mjs is the one place that knows which values belong to
which environment; scripts/deploy.mjs, scripts/apply-migrations.mjs,
scripts/backup.mjs, scripts/check-public-views.mjs and
scripts/bootstrap-admin.mjs all take --env test|uat|production
(default test) and print the environment and Supabase project ref before
doing anything. --env uat stops at once while .env.deploy.uat does
not exist, which is until the cutover.
.env.deploy.production (gitignored) holds the production
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY and
SUPABASE_SERVICE_ROLE_KEY (and SUPABASE_DB_PASSWORD for backups);
anything it doesn’t set — currently the whole Google Drive set — falls
through to .env.local. Next.js and OpenNext never
read that file on their own, which is what keeps a production build from
happening by accident: only scripts/deploy.mjs puts its values in the
shell.
Each environment’s values file also carries NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
the same value on the dev machine and on the Pi, so the Worker’s build and the
Pi’s build accept each other’s forms (docs/pi-hosting.md, step 1b;
node scripts/actions-key.mjs --generate makes one).
Deploying to Cloudflare
The app deploys to Cloudflare Workers through the @opennextjs/cloudflare
adapter (wrangler.jsonc, open-next.config.ts). Google Drive access is a
plain-fetch client (src/lib/google/drive.ts) specifically so it runs on
the Workers runtime — the googleapis SDK does not (see docs/decisions.md).
-
Log in to Cloudflare (once per machine)
npx wrangler loginRun it from a terminal you can leave open while you click Allow in the browser it opens — it gives up after about five minutes. If PowerShell refuses with “running scripts is disabled”, use
npx.cmdinstead ofnpx(the.cmdshim skips the execution policy).Every build goes through
scripts/win-junction-symlinks.cjs, which on Windows turns the directory symlinks OpenNext creates for Next’s hoisted ESM packages into junctions — real symlinks need Developer Mode or an elevated shell, and the build dies withEPERM … symlinkwithout it. It is a no-op elsewhere.Builds are safe to run while a
next devis up.next buildandnpm run typechecktype-check withtsconfig.build.json, which istsconfig.jsonminus.next/dev— the route types the dev server rewrites on every compile. Before that (2026-09-22) a build that read them mid-write died with a wall ofTS1005inroutes.d.ts/validator.ts. The editor still usestsconfig.json, so it sees the live dev types. -
Preview locally on the Workers runtime — what
next devcan’t simulate. It reads.env.localdirectly, no extra setup:npm run preview -
Deploy to Test
npm run deploy:testBuilds with the dev Supabase values, empties the env-file snapshot OpenNext bakes into the bundle (
scripts/strip-baked-env.mjs— check itsremoved N env var(s)line appears), and uploads thelanna-animal-care-testWorker. Add--secretsafternode scripts/deploy.mjs --env testto (re)upload the five runtime secrets from.env.localfirst — needed once per Worker and whenever a value changes;npx wrangler secret list --env testshows what is set.Every production release also goes to test, from the same commit (standing rule, 2026-09-24; the checklist is in
docs/release-smoke-test.md). Test is wheremainis exercised on the Workers runtime, so test drifting behind production makes it useless as a pre-production check — on0.1.0production shipped while test sat on0.0.1. Test being a few commits ahead between releases is expected and healthy; behind is the failure. Deploy test first where the release allows it, since a build that is going to fail should fail somewhere that is not the live site.Two consequences of how the builds work. Each environment needs its own build:
next buildinlines the Supabase URL and anon key, so--skip-buildcannot be reused across environments without shipping dev-pointing code to production — budget twice the time, not one build deployed twice. And check what is already on test before overwriting it: test holds one build, and it is sometimes deliberately an integration build that exists nowhere else (on 2026-09-23 it heldmain+ cashflow +claude/utc-todayfor an overnight timezone check, which a blind test deploy would have destroyed).Syncing the two means the same code against different databases — test runs the dev project. It catches a broken build, a Workers-runtime problem or a UTC-in-Workers bug; it will not reproduce anything data-shaped.
-
Deploy to UAT or Production
npm run deploy:uatnpm run deploy:prodSame steps with
.env.deploy.uat/.env.deploy.production, and a guard: it refuses unless the checkout ismain, clean, and identical toorigin/main, so what the customer tests and what is live are always commits GitHub has. Merge first, then deploy. Every environment, Test included, also refuses when its env file points at a projectsrc/lib/app-env.tswould badge as something else — which is whydeploy:uatfails today: UAT has no project of its own until the cutover.Why the strip step matters. The OpenNext adapter copies everything in
.env.localinto the Worker bundle as a fallback for any variable not set on Cloudflare — convenient fornpm run preview, but it would ship dev credentials (and unrelated things likeSUPABASE_ACCESS_TOKEN) to production. With the snapshot emptied, a secret you forgot to set fails loudly at runtime instead of silently using the dev value. To preview the stripped build locally the way production runs, put the five values in a gitignored.dev.varsfile (wrangler’s local stand-in for secrets).Never interrupt an OpenNext build on Windows (2026-09-23, which cost four deploy attempts). The bundling step rewrites symlinks under
node_modules— that is whatscripts/win-junction-symlinks.cjsis managing — so killing the build mid-way can leave a package as an empty directory. Here it gutted@react-pdf/reconciler, and the damage only surfaced on the next build asModule not found: Can't resolve '@react-pdf/reconciler'pointing atsrc/lib/archive/resident-summary-pdf.tsx, several minutes into a run that looked fine. If a build has to be stopped, treatnpm cias mandatory before the next attempt;node -e "…"overpackage-lock.jsoncomparing each entry against apackage.jsonon disk is how the two gutted packages were found. Prefer letting a bad build finish and rolling back after (the Pi:./scripts/pi/deploy-pi.sh --ref; the Worker fallback:npx wrangler rollback --env production) over killing it.npm cialone is not always enough, and neither is that audit. Later the same day a worktree had every one of the 713 packages present with itspackage.jsonintact — so both the audit above andnpm ciitself reported nothing to do — while files inside packages were missing:next/server.d.ts,next/headers.d.ts,next/types.d.ts, deleted by an interrupted purge.npm cidecides from package presence, so it no-opped, andnpm rebuildrestored the.binshims without restoring any file. The only thing that caught it was running the compiler:error TS7016: Could not find a declaration file for module 'next/server'. So when a build fails on missing types or a missing module that the lockfile clearly contains, do not trust a package-level audit — purgenode_modulesoutright and reinstall. On Windowsrm -rfandrmdir /s /qboth stall or fail on the deepnode_modulestree (the Cloudflare SDK’s resource folders exceed the 260-character path limit); what works is mirroring an empty directory over it and then removing it:robocopy %TEMP%\empty node_modules /MIR /NFL /NDL /NJH /NJS /R:1 /W:1 rmdir /s /q node_modules npm ci --no-audit --no-fundCapture real exit codes when running the gates.
npm run build | tailmakes$?the exit status oftail, not of npm, so a failed build reports success — redirect to a file and read$?, or use${PIPESTATUS[0]}. A test plan ticked from piped output is ticking something that never passed.node scripts/gates.mjsruns all four (typecheck, lint, test, build) and prints each exit code, which is why it exists.There is a test runner:
npm test. It is Node’s built-innode --test(no dependency) over the*.test.mjsfiles beside the pure security modules (public-paths,next-path,same-origin,can,password-change,worker/origin,csp-report,translations). It takes about a second and needs no database or network.scripts/test-support/lets the tests import the.tsfiles as written. The 130-oddscripts/check-*.mjsare still separate harnesses, several of them database-driven. Why and how:docs/decisions/2026-10-12-node-test-runner.md.Tracked files are CRLF on disk — preserve the line endings when editing one by script. The repository stores LF, but Git for Windows ships
core.autocrlf=truein its system gitconfig, so every checkout writes\r\n(git ls-files --eolshowsi/lf w/crlf)..gitattributespins onlyscripts/pi/*to LF. Git keeps reportingLF will be replaced by CRLF, which is easy to read as noise, and Git Bash’sgrep '\r$'finds nothing on these files, so a quick check says they are LF when they are not;tr -cd '\r' < f | wc -ccounts them. Two consequences, both hit while cutting0.2.0on 2026-09-24. A string anchor written with\nsilently fails to match, so a script that edits the file reports something likeunreleased opening not foundand looks like a parsing bug in the file it is reading. Worse, writing LF back converts the whole file, so a seventeen-line change arrives as a whole-file diff and the actual edit is invisible to review. Detect the existing endings, edit with\ninternally, and write back what you found:const raw = readFileSync(f, "utf8"); const crlf = raw.includes("\r\n"); let s = crlf ? raw.split("\r\n").join("\n") : raw; // …edit s with \n anchors… writeFileSync(f, crlf ? s.split("\n").join("\r\n") : s);ERROR Failed to copy … color-stringand… data-uri-to-bufferare cosmetic. They appear duringBuilding server functionon every run, with or without a clean.open-next, a dev server, or a freshnode_modules, and the packages land in the bundle anyway — verified in.open-next/server-functions/default/node_modules/after a successful deploy. Do not go looking for a missing dependency; three separate diagnoses of these lines were all wrong. Note also that a greennext buildproves only build-time resolution: Turbopack resolves the server-component import graph, which is why the reconciler failure above was fatal, but OpenNext’s copy step is a later, separate stage, so build success alone does not prove the bundle is complete. And adeploy:prodfailure is not necessarily a code problem — CI passed onf32f2c1three times while this machine could not build it, because CI runsnpm cion a clean runner and this checkout’s tree was damaged.The hostnames are Workers custom domains (
routesinwrangler.jsonc) on the zone registered in the same Cloudflare account, so Cloudflare manages DNS and certificates; the*.workers.devURLs are disabled. Every origin an environment answers on must be in its Supabase project’s Redirect URLs (step 6) ashttps:///auth/callback, since sign-in and password reset redirect through the requesting origin. -
Database migrations. Dev/Test first, production once the branch is merged (CLAUDE.md has the rules). First, what production has not run — it compares production’s
schema_migrationswith the files onorigin/main, names both halves (files not applied, rows with no file) and exits 1 if they disagree:node scripts/apply-migrations.mjs --drift productionProduction and UAT refuse to apply, dry-run or baseline from a checkout whose
supabase/migrations/differs fromorigin/main’s in any way, before reading an env file or reaching the database; there is no override, so merge first and run from the main checkout.node scripts/apply-migrations.mjs --env production --dry-runnode scripts/apply-migrations.mjs --env productionOn a fresh project that applies every file from
0001up (2026-09-21: all 63 ran clean on the new production project); otherwise only what is pending. Then confirm the public views are readable and not writable by the anonymous role, and thatanonis refused on every other table and view — Supabase’s default privileges grantedanoneverything on every new object, which0081_anon_view_grants.sqlrevokes. Likewiseanonmay execute only the five functions the public site calls, and is refused every other RPC (0082_anon_function_execute.sql). The check lists the Data API’s tables, views and functions withSUPABASE_SERVICE_ROLE_KEY, so a new one is covered without being added to the script:node scripts/check-public-views.mjs --env productionAfterwards, load
/,/adoptand/our-worksigned out and confirm residents and stories actually appear, then set the hero photo, contact details (email, phone, LINE, map link, visiting hours) and the wording of the story, how-to-adopt, Foster, Volunteer and Donate pages at/admin/website— the pages ship with placeholder copy (bank details on Donate are literally “(bank name)”) that must be replaced before the site is announced. The same page picks the optional “Pet of the week” from the publicly listed residents; project stories are published from/projects/[id](“Show on website”). Open Graph previews take the page’s own host for the image URL, so nothing needs configuring — setNEXT_PUBLIC_SITE_URLonly if the site ever sits behind a proxy that rewrites the host. (scripts/deploy.mjssets it to the environment’s own origin for every Worker build and refuses a leftover env file naming another environment;write-env.mjsis Pi-only.) -
Auth on a new Supabase project. Logins are admin-provisioned, so a fresh database has nobody who can sign in; seed the first admin, then sign in with Google using that address (it links automatically) and create everyone else at
/admin/security:node scripts/bootstrap-admin.mjs --env production [email protected]In the project’s Authentication settings (all done for production on 2026-09-21 via the Management API): URL Configuration → Site URL is the production origin and Redirect URLs has
https:///auth/callbackfor every hostname; Providers → Google uses the same OAuth web client as dev, which means the client in Google Cloud Console must list the new project’shttps://.supabase.co/auth/v1/callbackas an authorised redirect URI. SMTP Settings → Resend, field by field indocs/email-sending.md§2 — Supabase’s built-in mailer is for development only and is rate-limited to a handful of messages an hour, so Forgot password? only works in production once this is done. The reset email template can carry the shelter’s name and logo. -
Every deploy is a release. Release notes live in
src/lib/releases.tsand show at/releasesfor every signed-in role. A PR that changes something users notice adds a line tounreleased(the test plan asks, andcheck-test-plan.mjsflags a UI change that has no line and no stated reason); before deploying, a small release PR moves those lines into a new numbered entry and setspackage.json’s version to match (the rules are at the top of that file).deploy.mjsrefuses a UAT or production deploy that has unreleased notes or a version mismatch, tags the Worker version with the release (dashboard → Workers → Deployments), and after deploying emails a major release that is new to the site to that environment’s admins, with[UAT]/[Production]in the subject;--no-mailskips the email. Test never sends. The one-off Cloudflare setup the email needs — verifying each admin’s address under Email Routing, free — isdocs/email-sending.md§1.
Backups
Free-tier Supabase projects have no automatic backups or point-in-time
recovery, so production’s safety net is scripts/backup.mjs: a pg_dump
(custom format) of the public, private and auth schemas — every record,
the views and functions the app keeps in private, and the accounts that can
sign in — encrypted with age
before it goes anywhere, uploaded to a Backups/ folder under the Drive
root as lannacare--.dump.age, keeping the newest twelve per
environment (older ones go to the Drive trash, which empties itself after
30 days).
Encryption is not optional and uses a public key (security assessment
DB-1, 2026-09-30). The machine that runs the backup holds only the
administrator’s public key (BACKUP_AGE_RECIPIENT=age1… in
.env.deploy.production, or --age-recipient), so it can write a backup that
nobody on that machine can read. The private key is held by the
administrator in two places only: an encrypted USB drive and their Apple
Keychain. backup.mjs refuses to run without a
recipient, refuses a private key in that slot, never writes a plaintext dump
to disk (the dump is encrypted in memory), and leaves out the session tables
(auth.refresh_tokens, sessions, mfa_*, one_time_tokens,
flow_state). It also no longer prints the Drive link, since the output is
kept in backup.log. Because mfa_* holds the authenticator secrets, after
a restore everyone re-enrols their authenticator app (the recovery in
“2-step verification” above).
Making the key pair — once, by the administrator, on their own machine (not the Pi, not in this repo; the private key is never pasted into chat, an env file or a ticket):
age-keygen -o lannacare-backup-key.txt
(winget install FiloSottile.age on Windows, sudo apt-get install age on
Debian.) It prints Public key: age1…. Save the whole file in the password
manager (the AGE-SECRET-KEY-1… line is the private key), then delete the
file. Put only the age1… public key in BACKUP_AGE_RECIPIENT on each machine
that takes backups. Several keys may be listed, comma-separated (a second
administrator, or a sealed spare); any one of them can decrypt. Where the dumps go in Drive:
by default Backups/ under GOOGLE_DRIVE_ROOT_FOLDER_ID; set
BACKUP_DRIVE_FOLDER_ID to a folder id and backup.mjs uploads straight into
that folder and the status tile reads it, so Backups/ can sit outside the
photo tree, shared with one person (decisions/2026-10-02-backup-encryption-rollout.md).
The upload prefers its own Google token (BACKUP_GOOGLE_OAUTH_CLIENT_ID, _CLIENT_SECRET,
_REFRESH_TOKEN); without it backup.mjs stops before the dump unless --shared-drive-token is
given, which uses the app’s token with a warning. The Pi’s cron line carries that flag (set 2026-10-13)
(docs/pi-hosting.md, “Backups on this Pi”). A lost private
key means every encrypted backup is unreadable, so keep a second copy of it
somewhere as safe as the first. To change the key later, install the new
public key and keep the old private key for as long as any backup made with it
is kept (fourteen days under the daily job).
node scripts/backup.mjs --env production
node scripts/check-backup-encryption.mjs is the offline check of all this
(round trip with a throwaway key, exclusions, no plaintext, no link); it needs
no database or key of the shelter’s.
--keep-days N removes dumps older than N days (the daily job uses 14), but
never the newest five whatever their age, and nothing at all unless that run’s
upload was confirmed; --keep N instead keeps the newest N (default 12). Give one,
not both. node scripts/check-backup-prune.mjs is the offline check of those
rules. --local writes the encrypted dump there
instead of uploading (for a restore rehearsal); --local-copy uploads
and keeps a copy there, removing local copies by the same retention
after the upload is confirmed and printing each one it removes. The folder
must be outside the repo; it is created 0700 and each dump 0600. It
prints the environment and project ref first, like every other script, and
exits non-zero if any step fails.
Where it runs: the Raspberry Pi. Since 2026-09-30 the backup is the
Pi’s cron job (daily at 03:00 Thailand time since 2026-10-12; it was Sundays before), the same box that hosts the site
(docs/pi-hosting.md):
0 3 * * * cd $HOME/Animal_Shelter_App && node scripts/backup.mjs --env production --keep-days 14 --local-copy $HOME/backups/lannacare >> $HOME/backups/backup.log 2>&1
Drive stays the off-site copy: the Pi is in the same house as the origin it
protects, so its own copies guard against a bad Drive upload or a revoked
token, not against the house. The dumps live in ~/backups/lannacare, readable
only by the Pi’s user and nowhere under the repo, and encrypted, so a copy of
that folder is useless without the administrator’s private key.
~/backups/backup.log is the run history; ls -l ~/backups/lannacare shows
the newest dump and its date.
The steps below describe the setup on any machine; the Pi’s own are in
docs/pi-hosting.md.
One-time setup on the machine that runs it:
-
PostgreSQL 17 command-line tools — the projects run Postgres 17 and
pg_dumpmust not be older than the server. No local server is needed. On the Pi (Debian 13):sudo apt-get install postgresql-client-17; trixie’s own package is 17, so no extra repo is needed (Debian 12’s is 15 and would refuse a 17 server). The dev machine has the “binaries” zip from (thewingetinstaller kept failing with a 403 from EDB’s CDN) unpacked so thatpg_dump.exesits at%LOCALAPPDATA%\Programs\PostgreSQL\17\bin\pg_dump.exe— the zip’sbinandlibfolders are enough to back up; the restore rehearsal (below) also runs a throwaway server, which needs the zip’ssharefolder beside them (added 2026-10-13, when its absence stoppedinitdbwith “postgres.bki does not exist”). The script looks there and underC:\Program Files\PostgreSQL\(where the installer would put it) itself; nothing needs adding toPATH(setPG_DUMPto point it elsewhere). -
The database password —
SUPABASE_DB_PASSWORDin.env.deploy.production(and in.env.localfor the dev project). It is the password chosen when the Supabase project was created; Project Settings → Database resets it if it was never written down. The dump goes over Supabase’s session pooler on port 5432 (the directdb..supabase.cohost is IPv6-only), whose host and user the script reads from the Management API withSUPABASE_ACCESS_TOKEN. -
The administrator’s public key —
BACKUP_AGE_RECIPIENTin.env.deploy.production(see above). Noageprogram is needed on the backup machine: the script encrypts itself (theage-encryptionpackage). -
The daily schedule — on the Pi, the cron line above, and nowhere else. The Windows laptop also ran one until 2026-10-04, when the Pi’s first scheduled Sunday run was confirmed (both machines backed up that morning, which is why Drive holds two files for it) and the laptop’s task was unregistered.
scripts/backup-schedule.ps1stays in the repo for registering it again on a machine that needs it, with-Removeto unregister andGet-ScheduledTaskInfo -TaskName "Lanna Care production backup"to show the last run and its result (0 is success).A scheduled run can still be missed — the Pi can be off, as it was for an hour on 2026-10-02. Settings → System status makes that visible: its Daily backup tile reads the newest
lannacare-production-*.dump.ageinBackups/and turns amber after 2 days, red after 3. The Drive file’s own creation time is the record of the run, so nothing else needs storing.
System status (/admin/status, admins only) runs every health check
server-side with a timeout and caches the results for a minute; the checks
are small functions in src/lib/status/. Its visitor count needs two
optional values in the environment’s .env.deploy.production:
CLOUDFLARE_ANALYTICS_TOKEN (a Cloudflare API token with Zone → Analytics
→ Read on lannacare.org) and CLOUDFLARE_ZONE_ID. Put them in both
copies of that file — the dev machine’s, which deploy.mjs --secrets
pushes to the Worker, and the Pi’s, which deploy-pi.sh writes into
the Pi’s own settings (docs/pi-hosting.md, Day to day). The Pi serves the
pages, so with the values only on the Worker the Pi has nothing to read.
When a value is missing the tile names it and the machine it is missing
from (the Pi, the Worker or this laptop) and the file to put it in; on
production that is red, since the count is promised there, and on a dev
laptop grey. One of the pair without the other is red anywhere. The query
first ran against the live API on 2026-10-10 and answers at 7, 30 and 90
days (docs/decisions/2026-10-10-visitor-count-live.md).
Status alerts mail the admins when a health tile goes red and when it
recovers. A Worker cron trigger (*/15 * * * * on the test and production
Workers, wrangler.jsonc) hands POST /api/status/alerts straight to the
Next handler inside the Worker, bearer-authenticated with the service-role
key; the run is src/lib/status/alerts.ts and remembers what it saw in
status_alert_checks / status_alert_runs (0098). Mail goes out through
the STATUS_ALERT_MAIL send_email binding from STATUS_ALERT_FROM, so,
like release mail, only to addresses verified under Email Routing →
Destination addresses. STATUS_ALERT_SITE switches it on per environment.
To prove the path on a dev server, put STATUS_ALERT_SIMULATE_FAIL=drive
in .env.development.local (dev database only) and press Run the alert
check now twice; the mail is printed to the server log.
Rehearsing a restore — every quarter (first done 2026-10-13; next due January 2027). This proves a backup unlocks, loads, and holds every table, row, view, function, access policy and trigger, using a throwaway database on the administrator’s laptop. Nothing leaves the laptop and nothing touches the live site. It takes about ten minutes:
-
Make a folder outside the repo and outside OneDrive (OneDrive would upload the key):
C:\Temp\lannacare-restore. -
Put the newest backup in it: from Drive’s
Backupsfolder, download the newestlannacare-production-.dump.age(or ask Claude to copy one from the Pi’s~/backups/lannacare). -
Get the private key. It is kept in two places: the encrypted USB drive and the Apple Keychain.
- From the USB drive (better): plug it in and unlock it. If the key is there as a file, use that file’s path in step 4 directly. Nothing is copied, so there is nothing to delete afterwards.
- From the Keychain: copy the one line that starts
AGE-SECRET-KEY-1, open Notepad, paste it, then File → Save as into the folder from step 1, set Save as type to All files, and name itkey.txt. (With the type left on Text documents, Notepad saveskey.txt.txt, which Windows shows askey.txt; the script spots this and says so.)
-
In PowerShell, in the main checkout (
C:\Development\Animal_Shelter_App), with the key’s path after--identity:node scripts/restore-rehearsal.mjs C:\Temp\lannacare-restore\lannacare-production-.dump.age --identity C:\Temp\lannacare-restore\key.txt -
Read the last lines.
REHEARSAL PASSEDis the only good result; anything else names what did not come back. Each check printsokorFAILabove it. -
If the key came from the Keychain, delete
key.txt(it is the master key, unencrypted). Delete the folder, then empty the Recycle Bin. If it came from the USB drive, just lock and unplug the drive. The script has already deleted its database; it never writes an unlocked copy of the backup to disk.
The script (scripts/restore-rehearsal.mjs) does not trust pg_restore’s
exit status: every restore of these dumps reports two harmless errors
(schema "public" already exists, and auth.flow_state, a session table
the backup leaves out), so it exits 1 even on a good restore, and a dump missing whole
objects can still exit with the same status. The script instead checks that
those two are the only errors and compares every count against the archive
itself. A backup made before 2026-10-13 lacks the private schema and
fails this rehearsal (see below); that is correct, not a fault in the script.
What the rehearsal does not prove is that a Supabase project comes
back. Ownership, grants, Supabase’s own auth set-up and signing in only
exist on Supabase, and the roles the rehearsal creates are empty stand-ins.
That half is still unproved and is its own backlog item
(docs/decisions/2026-10-13-restore-rehearsal-local.md).
Restoring into a Supabase project (production after a disaster, or a scratch project) — never yet run against a real Supabase project. The steps below were corrected on 2026-10-13 by running them locally against a stand-in for a fresh project; each correction is noted. Run the rehearsal above on the chosen backup first, so you know the file itself is good. Then, on the administrator’s own machine, with the private key saved to a file out of the repo:
node scripts/decrypt-backup.mjs lannacare-production-.dump.age --identity path/to/key.txt
# (or, with the age program: age -d -i path/to/key.txt -o x.dump x.dump.age)
Then, in this order, with DB set to the project’s session-pooler
connection string
(postgresql://postgres.:@aws-0-ap-south-1.pooler.supabase.com:5432/postgres):
- The accounts first, rows only (the
authtables already exist in every Supabase project). Run it withPGOPTIONS="-c session_replication_role=replica"set, so a table can load before the one it points to (identitiescomes beforeusersin the dump and is otherwise rejected):pg_restore --dbname="$DB" --schema=auth --data-only --no-owner --no-privileges .dumpCorrected: the old recipe ran this step last, so the twenty links from app tables toauth.userswere created against an empty table and failed, quietly: the rows loaded, and the database was left without those checks. Whether Supabase’spostgresuser may setsession_replication_roleis not yet proved. - Create the
privateschema:create schema if not exists private;in the SQL editor. Corrected:pg_restore --schema=…restores the objects in a schema but never the schema itself. - The app’s data and structure:
pg_restore --dbname="$DB" --schema=public --schema=private --clean --if-exists --no-owner --no-privileges .dumpCorrected: the old recipe left outprivate, which holds no rows but is what about fifteenpublicviews and their access policies are built on, includingresident_current_state. node scripts/apply-migrations.mjs --status --env …to confirmschema_migrationscame across, and to apply anything newer than the backup.- Delete the decrypted
.dump: it is plaintext.
The dump has no session tables, so nobody is signed in afterwards, and authenticator apps are re-enrolled (see above).
Backups made before 2026-10-13 have no private schema (backup.mjs
dumped only public and auth until then). Their data is complete, since
private has never held rows, but restored alone they leave those views
missing. With only such a backup, restore it as above and then recreate private’s
views and functions from the newest migration defining each (0082 and
0086 moved them there; grep -l "private\." supabase/migrations/*.sql
lists the rest) before opening the app. That path has not been tried; with
any later backup it does not arise. The Pi’s nightly backups
include private only from the release after 2026-10-13, since the Pi
runs the released code.
Project structure
supabase/migrations/— database schema, RLS policies, and the core business-logic functions (immunization recording, bulk appointments, deceased-workflow cascade) described in the requirements doc.src/lib/supabase/— Supabase client helpers (browser + server).src/lib/google/— Google Drive client helper, including the per-record folder helpers (resident photos / blood tests / procedures, and the maintenance job folder that follows a job between status folders, and the project folder path that mirrors the project tree).src/lib/clinics/— clinic scope (which clinic a doctor account belongs to) and each clinic’s doctor list.src/lib/contacts/— the contacts vocabulary (contact_type), the one-tap link builders (tel:, LINE, Messenger, WhatsApp,mailto:, maps) used by the/contactslist and hub, and the Carer-only loader behind the resident hub’s foster / adopt picker.src/lib/shelter-friends/— Shelter Friends (0076), the businesses thanked on the public/friendspage:friends.tsholds the one gate on which contact types may become a Friend, the row types and the staff preview;public.tsis the public slice, which reads only thepublic_shelter_friendsview (published, live contacts, opted-in details only);staff-drafts.tsis the one exception, a count of unpublished drafts shown only to signed-in staff on/friends.src/components/FriendCard.tsxis the card both/friendsand the contact hub’s preview draw. Add a Shelter Friend (/management/shelter-friends/new) is the staff wizard that adds one end to end: it reuses intake’sWizardChrome, and saves throughaddShelterFriend(contact if new, then the profile row, then the logo viauploadFriendLogo); seedocs/decisions/2026-10-02-shelter-friend-wizard.md.src/lib/links/— checks for links staff paste into forms that end up on a public page (https only; Facebook links on facebook.com / fb.com).src/lib/maintenance/— the enclosure maintenance feature’s shared pieces: status vocabulary and colours, the job loader, and the after-change Drive folder sync used by the server actions.src/lib/my-tasks/—/my, “My tasks”: the commonMyTaskshape every source returns (types.ts) and one loader per source — maintenance so far — plus the count behind the menu badge. A new source is a loader besidemaintenance.ts; seedocs/decisions.md, 2026-09-26.src/lib/projects/— the project folder browser’s shared pieces: the category list, folder/photo loaders, and the Drive sync that keepsProjects///…matching the tree after renames and moves;public.tsis the read-only slice behind the public/our-workpages (thepublic_projectsviews and the locale/English-fallback helpers).src/lib/site/— the public site’s editable content: thesite_contentsingleton (contact details, paired Thai labels), thesite_pagesrows behind the story and the Foster / Volunteer / Donate / how-to-adopt pages, and the three-rule body format they are written in./privacyis the exception: its text is in the dictionaries, because Google’s sign-in consent screen links to it and it must never be blank.src/lib/tags/— the addresses programmed into enclosure QR codes and resident RFID cards (/e/,/r/, short redirects — seedocs/decisions.md, 2026-09-22) and the origin they are prefixed with;src/components/CopyTagLink.tsxis the copy control on the hubs and lists, andsrc/app/e//src/app/r/the redirects.src/lib/residents/— the public slice of a resident (public_resident_profiles, similar residents) and the adoption recommendation fields the intake and edit forms share.src/lib/translations/— free text across languages (0056): the row types, the loader for a record’stranslationsrows, the manager queue query, andlocalizedField()/localizedFromRow()that pick the approved other-language text by locale with the original as the fallback.src/components/TranslationPanel.tsxis the one editor, used by/management/translationsand under the fields themselves; the server actions are insrc/app/management/translations/actions.ts. Which fields are translatable is thetranslatable_fieldstable, not code (today: resident bio / temperament / past story, project story, photo caption, maintenance job title / description). Short labels (diet, medicine and setup-list names, units, places, website captions) are the other half (0166): a paired_thcolumn, registered intranslatable_labels, read on the page withlabel_translations()and written withset_label_th().labels.tshas the page’s types, groups and links, andlocalLabel(), which every screen uses to show a label in the reader’s language.src/lib/archive/— the deceased resident archive: the summary PDF, the offlineindex.htmlindex page written beside it in the resident’s Drive folder, the step that moves that folder toResidents/Deceased/, and its reverse for a death withdrawn as recorded in error.src/lib/manual/— the in-app user manual’s content (en.ts, English only so far), rendered bysrc/app/manual/page.tsxat/manualfor every signed-in role, filtered to the reader’s role by each topic’sroles(filter.ts;?view=allshows the rest, greyed). Its screenshots live inpublic/manual/and are regenerated, not edited:node scripts/manual-screenshots.mjsopens the machine’s Edge/Chrome on the running dev server, waits for you to sign in as an admin, and captures every screen the manual references. It then writes each PNG’s pixel size tosrc/lib/manual/screenshot-sizes.jsonso the page can reserve the space;--sizesrewrites just that file after a PNG is added or replaced by hand.src/lib/assistant/— the assistant’s brain: one small parser per intent underintents/, tried in the orderparse.tslists (the order is load-bearing — seedocs/decisions.md, 2026-09-23). Each one turns a typed sentence into aDraftwith nulls for whatever the sentence didn’t say, matching resident, enclosure and vet names against real rows and a small date/time vocabulary in English and Thai.data.tsloads those rows and the caller’s role;audit.tswrites theassistant_actionsrow (0070) every turn leaves behind, confirmed, cancelled or unrecognised. Version 2 (an LLM — see the “Assistant” section ofdocs/backlog.md) replaces this directory and keeps the rest.src/components/assistant/— the conversation: the message list and input, one preview card per writing intent, and the answers to the read-only questions. Rendered both by/assistantand by the header’s slide-over. Nothing is written until a card is confirmed, and Confirm callssrc/app/assistant/actions.ts, which runs the same server helper the matching page or form runs — the assistant has no write path of its own. Admin, management and staff can confirm writes; volunteers get the lookups (“where is …”, “who is in …”, “what is due this week”).worker/index.mjs— the Worker entry: edge cache → Pi through its tunnel → OpenNext render (docs/pi-hosting.md).worker/translations.mjs,worker/translate-engines.mjs— machine-drafted translations: on the Worker’s 15-minute cron, pendingtranslationsrows get adraftfrom Workers AI (or M2M100 / Ollama, viaTRANSLATE_ENGINE) for a manager to check; neverapproved. Needs theAIbinding andSUPABASE_SERVICE_ROLE_KEY;node scripts/check-translate-worker.mjstests it.docs/decisions/2026-10-04-translations-worker.md.scripts/pi/— the Pi origin: one-timesetup.sh,deploy-pi.sh, the systemd unit and cloudflared config.scripts/— one-off tooling:apply-migrations.mjs(migration runner),load-residents.mjs(bulk resident loads from a reviewed CSV — dry run by default,--applyto write; seedocs/decisions/2026-10-05-bulk-resident-loader.md; its run also lists possible duplicates of existing residents),correct-resident.mjs(removes a resident entered twice, keeping the original, optionally copying details such as the Thai name across; refuses anything with real history; dry run by default,--applyto write, receipt on the Desktop first; seedocs/decisions/2026-10-09-resident-corrections-script.md),upload-blood-work.mjs(the/upload-blood-workskill’s writer: loads lab reports from the Blood Work inbox on Drive as a clinic visit plus one blood test per panel with the report attached; dry run by default,--applyto write, test by default and--env productionwhen the skill’s lessons file says live; seedocs/decisions/2026-10-15-upload-blood-work-skill.md),audit-contact-map-links.mjs(lists contacts and Shelter Friends whose map link is dead, missing or has words after it, in plain words for whoever fixes them — read-only, dev by default,--env productionwhen asked; seedocs/decisions/2026-10-08-contact-map-link-audit.md),move-contact-map-links.mjs(moves a map link left at the front of a contact’s address into its Map link,map_url— dry run by default,--applyto write,--env productionwhen asked; seedocs/decisions/2026-10-08-contacts-address-and-map-link.md),check-public-views.mjs(go-live check),manual-screenshots.mjs(user-manual screenshots),appsheet-export.mjs+import-appsheet.mjs(the legacy data migration — seedocs/data-migration.md), the Google OAuth setup helpers, the deploy-time env stripper, andextract-yoga-wasm.mjs(refreshessrc/lib/archive/yoga/yoga.wasmafter a yoga-layout upgrade — seedocs/decisions.md, 2026-09-22).docs/— requirements, decisions log, backlog and the data-migration mapping/runbook.CLAUDE.md— the working rules for this repo (branching, migrations, finishing a feature). Written for Claude Code sessions, but they’re the rules for anyone committing here.
Day-to-day workflow
The short version of CLAUDE.md. Once a day, in the main checkout,
main is pulled and the backlog branch folded in; /plan-day does
this, reads the backlog and proposes two or three workstreams that touch
different parts of the app. Each becomes a worktree
(scripts/worktree.mjs new) with its own branch, port and Claude session,
and every commit auto-pushes (.githooks/post-commit). A feature is
finished by merging main in, passing node scripts/gates.mjs (CI runs
the same three on the PR), merging the PR, and scripts/worktree.mjs done — merges are serial, and the other streams sync after each one.
Schema changes go first as their own small PR, applied to dev with
node scripts/apply-migrations.mjs at merge time, so only one branch ever
carries a migration.
Рекомендуемые инструменты
Попробуйте другой запрос или уберите фильтр.
Установка
npx skillfish add lutanbennett/animal_shelter_app