Synaplan
开源 AI 软件平台,面向企业:自托管 AI 代理,符合 GDPR 的聊天机器人小部件,RAG 知识库,WhatsApp 与 Outlook AI,MCP,多模型路由。支持 Docker、Kubernetes 或在 Germ 托管。
AI 翻译
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗A babysitter app for two phones. A parent sets up the family space and invites a babysitter by email; the babysitter sees the schedule and each child's care info and checks off tasks while the parent watches progress live.
The site itself is plain HTML, CSS, and JavaScript served as static files (Vercel, no build
step). There is deliberately no package.json at the repo root; database tooling lives in
tests/, and .vercelignore keeps supabase/ and tests/ off the deployed site.
Pages: signin.html (Google sign-in, the data notice), onboarding.html (create a family space;
requires ticking the at-your-own-risk acknowledgement), people.html (parents: invite a sitter,
revoke invites, remove sitters), invite.html#<token> (accept an invite), and privacy.html /
terms.html. Every app page (index, parent, people, manage, schedule, care-log,
children, profile, gallery, info, food-allergies) loads js/auth.js, which keeps the page hidden until it has checked the
session: signed out goes to signin.html, no family space goes to onboarding.html, a parent's
home is parent.html, a sitter's is index.html, and a page for the other role redirects to the
right one. The Profile tab shows the signed-in account and signs out or deletes the account and its data. The children,
the schedule, care info (Good to know, food and allergies, emergency steps) and contacts come from
the family space in Supabase (js/family-data.js loads them into the shape js/data.js defines);
parents edit them on manage.html ("Family info" in the parent nav).
The Children tab (children.html, both roles) pins the current household (family name and next
sit) to the top of the page while the list scrolls under it (SI-68), then shows the family card
(primary contact, phone, and home address) and each child, with their age, stage, pinned note, and
any severe allergies. A sitter in several families switches household from the pinned card or the
family bar without leaving the tab: the pin shows the new household at once and the previous
family's children are cleared until the new family's load. Each child's page keeps the family name
pinned in its header. Tapping a child (children.html?child=<id>) shows that child's food and
allergies, preferences, and instructions, the family's contacts, and links to where things are and
emergency steps, which stay family-wide. A category with nothing in it says "No information
provided". Parents add, edit, and delete children and each child's items there too, and set the
home address and primary contact in Family info. Pages that list care info for the whole family
(the food page, the care info pages) tag each per-child item with the child's first name once
the family has more than one child. Children are shown by initials, never by photo.
The top of the sitter's page (index.html) is an "Important today" section: the parent's notes
for that day (day_notes: routine change, special instruction, schedule change, food or
medication, other), each opening in place to its details and marked "Updated " after a
parent edits it, or "No special updates for today." The day is today's local date (the browser's,
re-checked at local midnight); while a shift that started on an earlier local date is still open
(overnight, or never ended), the notes for that start date are shown too, under "Today" and the
start date's own heading. Parents pick the date for each note in the "Important today" card on
manage.html. The notes refresh live (Realtime), and if they
fail to load only that section says so.
Below it, the sitter's Home (SI-69) shows "What's next": the one next activity, with its name and
time, which is the earliest task not yet checked off whose time is now or later (re-checked every
minute, so a 3:00pm activity gives way to the next one at 3:01pm; tasks with no time are left out;
js/next-activity.js). After the last one it says "No more activities scheduled for today.", and
"Full schedule" opens the Schedule tab. Then the Checklist: the day's tasks with "x of y done",
checked off the usual way (a photo, an optional tag and note, then the "Add details" prompt). The
Schedule tab shows the same checklist under Current Shift for the sitter; both read the shift's
task checks, so a task checked or unchecked on one page shows on the other. With no tasks it says
"No checklist items for today." The side column has the emergency entry, the family's primary
contact only (contacts.is_primary, with a call button; "All contacts" opens the first child's
page on the Children tab, which lists every contact), or "No primary contact yet.", and the Good to
Know tiles. Home no longer previews the care log.
The Care Log tab (care-log.html, both roles; parents at care-log.html?as=parent, and
js/auth.js sends each role to its side) is the current session's log: the family's latest shift,
its entries oldest first, each with the child it is about (initials and first name), what happened,
the time, and who wrote it; details logged under completed tasks are listed in the same order,
labelled with the task. With no entries it says "No entries yet". The sitter's "New entry"
(care-log.html#new, so the phone's back button closes it) picks the child (skipped when the
family has one child) and takes a note of up to 1000 characters; the entry is saved to
care_logs (in demo mode, to the tab's sessionStorage) and shows for everyone in the family
live. If saving fails the form says "Your entry wasn't saved" with the reason and keeps what was
typed, so Save can be tapped again. New entry is offered only while the shift's date is in the
window (open or ended); before the sitter starts a shift the page says to start it on Home, and an
older session is read-only. Parents read the log and do not add to it.
Messages was removed (SI-66): there is no chat screen and no Messages item in the navigation.
messages.html is now a tiny page that sends old links and bookmarks to care-log.html, keeping
the query (?as=parent, ?demo=1; js/messages-redirect.js, with a meta refresh as the
fallback), chosen over a vercel.json redirect because it also works with the local
python3 -m http.server and needs no extra CSP rule. The messages table and its data stay: photo
messages, task proofs, and moments are rows of it. Parents can no longer reply under a photo (no
sitter screen showed replies once Messages was gone); replies written before stay under their
photo in the parent's Today feed, read-only.
The shift (start, end, reopen, history), task check-offs with photo proofs, moments, photo messages, and care logs are in Supabase (js/shift-data.js: Postgres, the private photos Storage bucket, and Realtime) for both roles; ?demo=1 keeps them in this browser (js/store.js). The parent's Today page (parent.html) shows today's shift status (not started / active / reopened / ended) with its history, "x of y done", the schedule (a parent can mark a task done without a photo, "Done by ", and end or reopen the shift), and a live feed of the shift's proofs and moments (Late, "Added after reopen", "Photo removed by ", earlier replies (read-only), "New" until seen via message_reads). New photos (proofs, moments, and the sitter's photo messages) and shift events raise an in-app banner and a polite live announcement (no push notifications); View scrolls to a new feed photo, or opens the Gallery when only photo messages are new (they stay out of the Today feed). Save photo uses the Web Share API with files on touch devices, otherwise a JPEG download, and adds it to the parent's Album (#album, photo_saves). Remove photo calls remove_photo and then deletes the object from Storage. The concept alert (concept_flags) and "Bring moment forward" (trigger_moment_now) are demo controls shown only on localhost; the concept alert is labelled "Concept — not real analysis". The Gallery (gallery.html, both roles; parents at gallery.html?as=parent) shows every photo of the family's shifts, oldest first with the newest at the bottom, under a heading per shift date, each labelled Task update, Photo message, or Moment, with a filter (native radios styled as chips); removed photos are skipped. The sitter's "+" (Add a photo) opens the phone's camera or library (<input type="file" accept="image/*">), then a dialog with the preview and an optional caption, and sends a photo message; it is disabled with the reason when the shift isn't open. A shift takes up to 20 photo messages; past that the sitter sees "Photo limit reached for this shift." Parents get "Remove photo" on each photo. On manage.html, "Remove from schedule" archives a task (archived_at, stamped by the database); past shifts keep it.
Parents book and edit sessions on the Schedule tab (schedule.html) in Schedule Details. Times
are picked with hour, minute (5-minute steps), and AM/PM selects, which phones show as scroll
wheels; a new activity starts at the session's start time. An end time earlier than the start
means the session ends the next day (SI-80): 7:00 PM to 1:00 AM is listed on the day it starts as
"7:00pm – 1:00am (next day)", and the dialog shows its length under the pickers ("6 hours, ends
1:00am the next day"). An end equal to the start is refused, so a session is always under 24
hours. Sessions may not overlap, across midnight too (Friday's 7:00 PM – 1:00 AM clashes with
Saturday's 12:30 AM); the app checks this (js/schedule-dates.js), the database does not. An
overnight session's activities after midnight sort after its evening ones, and the Current
Session/Shift card (and the Children tab's next-sit tag, "Now until 1:00 AM") keep showing it
after midnight until it ends, updating each minute without a reload. While it is under way, today's
list starts with "Continues from yesterday until 1:00am" (tap it to see that session) and offers
no Request Schedule, and the Current Session pencil edits that session (its own date stays
allowed). Saving a session longer than 12 hours asks first ("This session is 20 hours long,
ending 5:00am the next day. Save it?"), to catch an AM/PM slip. There is no caregiver picker: a session is not assigned to a sitter
yet. Parents pick which children a session
is for (every child by default, and for sessions saved before children were recorded) and which of
those children each activity is for; the sitter sees "For Jack" on an activity that is for only
some of the session's children. Adding, editing, or deleting an activity shows in the list at
once and is kept when the parent taps Save (closing without saving discards it); an activity
still being typed when Save is tapped is added, and Enter in the activity's name adds it rather
than saving the whole session. Delete Schedule removes the whole session, after a confirmation,
for everyone in the family.
Outside demo mode the bundled sample family is discarded before anything renders, so a
signed-in person never sees sample content; an empty family shows "No information provided".
A parent's edits (schedule, care info, contacts, children) reach every open page of the
family within a second or two, without a reload (Realtime; refetched on return to the tab too).
Open demo.html and pick Try as the babysitter (index.html?demo=1) or Try as a parent
(parent.html?demo=1). No sign-in is needed. The demo families are in js/data.js:
Each family has a profile, care info (food and allergies, preferences, where things are, instructions, emergency steps, contacts), today's schedule, upcoming sessions, and today's care log already started (two tasks done, with care log details on them). Testers can check off tasks, add care log details and photos, add Care Log entries about each child on the Care Log tab, edit the sitter profile, and, as the parent, add, edit, and delete children and their care info, contacts, where things are, and emergency steps on the Children tab, and schedule sessions on the Schedule tab. Everything is kept in the tab's sessionStorage, so closing the tab resets the demo for the next tester. The parent's "Important today" notes are read-only in the demo. The demo tools panels (moment trigger, "Start a new shift", concept alerts) show only on localhost.
?demo=1 (remembered for the browser tab) shows the demo families from js/data.js with a
"Demo mode" banner, as before accounts existed. It does not sign anyone in or out: signed out, the
banner links to sign-in; signed in, it reads "Demo mode — signed in as · Exit demo" (Exit
demo clears the flag). Sign-in, invite.html, and onboarding.html also clear it. Demo mode never
reads the database (signed-out requests can read nothing anyway) and never reads an account's
device data.
Demo data (js/store.js, ?demo=1 only: task photos, moments, messages, check-offs, care logs
and Care Log entries)
is in sessionStorage under carenest:demo:v1:<family>, with the demo family's edits under
carenest:demo:data:<family>; all of it ends with the tab. Signed-in people's photos and messages
are not kept on the device. Each account's device key is carenest:v1:<user id>, picked by the
auth guard; signed-out visitors get no key.
Signing out, or the guard finding no family space, removes that account's key; deleting the
account removes every carenest:* key in that browser. The pre-accounts shared key carenest:v1
is discarded on load (it can't be attributed to one account). The sitter profile used to be kept only on the device
under carenest:sitter-profile:<user id>; it now lives in the database, and the profile page
moves that old copy there once and then deletes the key.
Security headers (CSP with Google Identity Services' documented sources, frame-ancestors 'none', nosniff, Referrer-Policy, Permissions-Policy) are set for every route in
vercel.json. style-src includes 'unsafe-inline' because Google's sign-in script injects
its own <style> element and style attributes (the button renders broken without it);
script-src has no inline allowance. python3 -m http.server does not send these headers.
Config is in js/config.js and is public by design: the Supabase URL, the publishable key, and
the Google OAuth client ID. On localhost/127.0.0.1 the app uses the local stack
(http://127.0.0.1:55321, the CLI's built-in local publishable key) and shows a password
"Local development sign-in" form; every other host uses the hosted project. Run it locally with
python3 -m http.server 8000 from the repo root and open http://localhost:8000/signin.html.
Google is the only sign-in method, through Supabase Auth with Google Identity Services (GIS):
signin.html and invite.html load https://accounts.google.com/gsi/client and render
Google's button on our own origin (js/signin-widget.js), popup or FedCM only: no One Tap and
no auto-select. Google returns an ID token; supabase.auth.signInWithIdToken exchanges it for a
session. No redirect, so no tokens ever appear in the URL (detectSessionInUrl: false).tests/package.json) with a Subresource Integrity hash, so a changed file is refused. Google's
GIS script is evergreen and cannot be pinned or hashed.invite.html#<token>), which browsers never
send to a server, and invite.html sets <meta name="referrer" content="strict-origin"> so only
the origin ever leaves in a Referer. After accepting, the page replaces itself, so the token
leaves the tab's history.http://localhost:8000 for local Google testing) as an authorized JavaScript
origin.The Supabase schema is in supabase/migrations/. Everything below runs against a local
Supabase stack in Docker; nothing here touches a hosted project.
cd tests
npm install # pinned Supabase CLI + supabase-js
npm run db:start # local stack; ports are 553xx so it never collides with Carekeeper's 543xx
npm run db:reset # applies migrations, then supabase/seed.sql
npm run test:rls # authorization tests (refuse to run against a non-local URL)
npm run test:unit # care-log age parser, config port override, next activity, ... (no database needed)
npm run db:lint # supabase db lint
npm run db:stop
npm run db:env # which stack this checkout uses, and its ports
Local ports: API 55321, Postgres 55322, Studio 55323, Mailpit 55324.
Every npm run db:* and test:rls goes through tests/stack.sh. In the main checkout it changes
nothing. In a linked git worktree (git worktree add ../CareNest-x) it runs a separate stack:
project carenest-<worktree folder>-<slot> with its own containers and volumes, and every port
shifted by slot × 100, where the slot (1-40) is derived from the folder name, so one session's db reset cannot wipe another's
data. npm run db:env prints the slot and ports. If two worktrees land on the same slot,
db:start fails with "port is already allocated"; pick a free one with
CARENEST_STACK_SLOT=<n> npm run db:start and use the same variable for that worktree's other
commands (without it they find no running stack and fail). CARENEST_STACK_SLOT=0 means the main
checkout's shared stack. A stack started before this script existed (or under a hand-picked
project_id) is not stopped by db:stop; stop it once by name with
npx supabase stop --project-id <old id> if it holds the ports. Stop stacks you are done with (npm run db:stop); each one holds a few GB of Docker
memory. When Docker is short on memory and db:start times out on health checks, start only what
the tests need: npm run db:start -- -x studio,logflare,vector,edge-runtime,imgproxy,postgres-meta.
To point the site at a worktree's stack, serve that checkout on its own port and open it once
with ?supabasePort=<API port> (for example http://localhost:8001/?supabasePort=57521). The
page remembers it for that origin; ?supabasePort= (empty) goes back to 55321. The override
works only on localhost, only changes the port on 127.0.0.1, and only accepts stack API ports
(55321 plus a multiple of 100, up to 59321).
supabase/seed.sql loads the demo family from js/data.js (Jack Thomas and Kylie Thomas: the
day's schedule, care info, food and allergies, emergency steps, contacts with Mary Ann as the
primary contact, the home address, and the hospital) into a household
with id 00000000-0000-4000-8000-00000000c0de that has no members. Users cannot be created
from SQL, so sign up on the local stack, then attach yourself as its parent in Studio's SQL
editor:
insert into public.household_members (household_id, user_id, role, email, display_name)
select '00000000-0000-4000-8000-00000000c0de', id, 'parent', lower(email), 'Mary'
from auth.users where email = '[email protected]';
Seeds run only on a local supabase db reset; never run seed.sql against a hosted project.
| Table | Holds | Who can write |
|---|---|---|
households | Family space name, optional home address, and the old single-child columns child_name and child_age_label (transitional: only tabs still open on the pre-SI-45 client write these) | Created by create_household, which also creates the first child; parents edit the name, the child columns, and the home address. Only current members read the address |
children | One row per child: name, age label, stage label, pinned note, order | Members read; parents add, edit, and delete. Reads, adds, and edits use column grants (delete is table-level): a child's household, id, and creation time are fixed, so a child never moves to another family space |
household_members | Parent and caretaker roles. An account is a parent of at most one family space (a partial unique index) and a sitter in any number, including while a parent elsewhere | Added only by RPCs; parents remove caretakers |
invites | Email-bound invites (token hash, 7-day expiry) | Created by create_invite; parents revoke |
care_items | Preferences, where things are, instructions, emergency steps, food/allergies (with severity). Food, preferences, and instructions belong to one child (child_id set, enforced by a check); where things are and emergency steps are family-wide (child_id is null, enforced by a check) | Parents. A composite foreign key means an item can only point at a child of its own family space. Deleting a child deletes that child's items |
contacts | People and places to call (including the hospital, with address), shared by the family space; at most one primary contact per family space (is_primary, a deferrable exclusion constraint, so one update can move primary to another contact) | Parents |
day_notes | "Important today": notes for one calendar date (category, title, detail); who wrote and last edited each, and when | Parents add, edit, delete; members read. Authorship and edit times are set by the database (a trigger), so "Updated" cannot be forged; a note never moves to another family space |
plans, tasks | The child's day (one plan per family space): ordered tasks with time, detail, location, care note, usual hours |
public. The browser will only ever hold the public publishable
key; there is no service-role or secret key in the app.public get no anon/authenticated privileges (schema-scoped default privileges revoked).
Postgres also gives every new function EXECUTE to PUBLIC in every schema by default, which
a schema-scoped revoke cannot remove, so the migration additionally revokes that globally for
functions created by postgres: any function a later migration adds, in any schema, must be
granted explicitly to be callable.postgres in a later migration
is NOT executable by anon or authenticated until it is explicitly granted
(grant execute on function ... to authenticated). This also covers helpers used in column
defaults, CHECK constraints, RLS or storage policies, and functions from extensions created
by postgres. The migration author must add the grant; a missing grant fails closed with
42501 (permission denied).sitter_profiles row; only they can read or change
it, and the row's owner is set by the database (auth.uid()), never by the client. Parents
see a sitter's profile only through household_sitter_profiles(household), which returns
rows to a parent of that family space, only for its current caretakers, and returns about,
phone, and availability as NULL unless the sitter chose to share them. That filtering is
server-side, so hidden fields and the private reminders preference never reach a parent's
browser. A profile saved on a device before this change is moved to the database once and
the device copy is deleted.20261102000000_carenest_children.sql gave every household exactly one
children row (blank ones included) and moved its food, preferences, and instructions to that
child. The client reads and writes children and per-child care_items directly
(20261106000000_carenest_children_client.sql): creates the first child,
and onboarding names it. A tab left open on the old client from before that deploy still writes
, , and care items with no child id, so two
transitional triggers stay for a grace period: a change to one of the household's child
columns copies that column to the first child (lowest position), and a per-child care item
saved with no child goes to the first child (creating one if every child was deleted). The
copy runs one way only: a direct edit of a child does not change the household's columns. Until
it reloads, an old tab also shows every child's food items in one list under the first child's
name (it shows more restrictions, never fewer), and an allergy it saves goes to whichever child
is first, so reordering children in Family info changes which one. The
check (validated by ) makes a child required
for food, preferences, and instructions. Once no old tab can be open, a later migration drops
the two triggers, then , then the household child columns, in that order.The migration does not depend on dashboard toggles, but two settings must match it:
Schema changes reach the hosted project (carenest, ref ibwshlbhszznfhmeckda) only through
supabase db push, which records each version in supabase_migrations.schema_migrations, so
the project's history shows exactly which migrations it has. Never apply a migration by pasting
SQL or with supabase db query: that skips the history, and the next db push would try to run
it again. From tests/, with the pinned CLI installed (npm ci) and logged in
(npx supabase login):
npm ci
npx supabase db push --workdir .. --project-ref ibwshlbhszznfhmeckda --dry-run
npx supabase db push --workdir .. --project-ref ibwshlbhszznfhmeckda
npx supabase migration repair --workdir .. --project-ref ibwshlbhszznfhmeckda --status applied <version>).main, push, and merge right away. Vercel deploys main on merge, so the new pages must
never reach a database without their tables and functions (and the live site must keep
working against the migrated database until the merge lands).db push would report up to date while production differs). Fix it with a new migration.db push --include-all or migration repair --status reverted here. If the dry run
says a remote version is missing locally, rebase on main; if a new migration's version is
older than one already pushed, rename it to a newer timestamp so it runs in order.20261017000000 were applied as SQL before the history existed and were
marked applied with migration repair after their hosted schema was compared with the repo.The cleanup job reads two Vault secrets, which are not in any migration because one is a secret
key. Its deletes are permanent: database backups do not restore Storage objects. So first, in the
SQL editor (after db push), review what the first runs will delete, and move anything that must
stay out of the photos bucket:
select o.name, o.created_at
from public.photo_orphans() n (name)
join storage.objects o on o.bucket_id = 'photos' and o.name = n.name;
Then create a dedicated secret key (Dashboard → Project Settings → API Keys → Secret keys → New,
named photo-cleanup) so it can be revoked on its own, and run:
select vault.create_secret('https://ibwshlbhszznfhmeckda.supabase.co', 'carenest_project_url');
select vault.create_secret('<the sb_secret_... key>', 'carenest_photo_cleanup_key');
Check the URL and key right away, without waiting for an orphan. Run this on its own (pg_net sends the request only after the statement commits):
select net.http_get(
(select decrypted_secret from vault.decrypted_secrets where name = 'carenest_project_url')
|| '/storage/v1/bucket/photos',
headers := jsonb_build_object('apikey',
(select decrypted_secret from vault.decrypted_secrets where name = 'carenest_photo_cleanup_key'))
) as request_id;
A few seconds later, with the returned id, this must show 200:
select status_code, left(content, 200), error_msg from net._http_response where id = <request_id>;
After a run (each hour at :17), the job's latest run should have succeeded and, when there was
something to delete, Storage should have answered 200 (pg_net keeps responses for 6 hours).
Storage answers after the sweep has committed, so a rejected delete (a wrong or revoked key)
shows in the check job, carenest-photo-cleanup-check at :47: it fails with
photo_cleanup_failed, and its return_message has the status and response. The refused
paths are skipped with backoff (1 to 2 hours, doubling with each failure up to a day) so newer
orphans still go; they are listed in public.photo_sweep_failures. To retry them at the next
run, update public.photo_sweep_failures set retry_at = now();. A manual
select public.sweep_orphan_photos() settles the earlier requests itself, so after one, look
at last_status in public.photo_sweep_failures rather than at the check job.
pg_net briefly stores the key in net.http_request_queue: never add the net schema to the
Data API's exposed schemas.
select j.jobname, d.status, d.return_message, d.start_time
from cron.job_run_details d join cron.job j using (jobid)
where j.jobname in ('carenest-photo-cleanup', 'carenest-photo-cleanup-check')
order by d.start_time desc limit 6;
select status_code, left(content, 200), error_msg, created from net._http_response
order by created desc limit 3;
To rotate the key, select vault.update_secret(id, '<new key>') from vault.secrets where name = 'carenest_photo_cleanup_key';. To pause the cleanup, pause both jobs (the check alone would fail
once pg_net drops the last response),
select cron.unschedule('carenest-photo-cleanup'); select cron.unschedule('carenest-photo-cleanup-check');;
to resume, select cron.schedule('carenest-photo-cleanup', '17 * * * *', 'select public.sweep_orphan_photos()'); select cron.schedule('carenest-photo-cleanup-check', '47 * * * *', 'select public.check_photo_sweep()');.
Parents; clients update only a plan's schedule (its household and title are fixed); archived tasks are frozen |
appointments | Care booked ahead on the plan: date (the day the session starts), start and end time (an end earlier than the start ends the next day, an overnight session; never equal to the start, appointments_end_differs_from_start), note, activities (each with the children it is for), and the children the session is for (child_ids). No overlap rule in the database (the app checks) | Parents book and edit the date, times, note, activities, and children, and delete a session; members read. Household and plan are fixed (an appointment never moves to another family space or plan). Every id in child_ids must be a child of the same family space (a trigger, child_not_found) |
schedule_requests | A sitter's "Request Schedule" reminder: family space, date, who asked, when, and when it was resolved | Caretakers ask (today or later in the app; the database accepts yesterday UTC up to a year ahead, never for a day that already has an appointment; one open request per day); members read; parents dismiss (set resolved_at). requested_by is set by the database. Booking or moving an appointment onto that date resolves it (a trigger). Nobody deletes |
visits | One babysitting shift per plan per date (yesterday, today, or tomorrow); start, handoff note, current end, moment time | Members start and write the note (dates in the window only); any member can end_visit and reopen_visit |
task_checks | Which tasks are done in a shift, by whom, and when (checked_at) | Only complete_task (photo required for caretakers, optional for parents), while the shift is open and in the window; members uncheck |
shift_events | Append-only shift history: ends, reopens, and unchecks of original-shift tasks after a reopen, with who, which task, and when | Nobody directly; only end_visit, reopen_visit, and the uncheck trigger write it. Members read |
care_logs | A shift's care log. Details on a task completed in the shift: meal (amount all/most/some/none and/or a note), bottle (ounces, optional note), or care (a note). Care Log page entries (SI-63): note, no task, about one child (child_id, with the child's name kept in child_name). Who wrote it, their role, and when | Members add task details to a checked task, and entries about a child of the same family space, while the shift's date is in the window (open or ended); the app offers New entry to sitters only. Author, role, household, time, and the child's name are set by the database. A composite foreign key keeps the child in the same family space; deleting a child keeps its entries (child_id becomes null, the name stays). Append-only: nobody edits or deletes; an edit is a new row |
messages | Chat, photo messages (chat with a photo and its shift, caption optional), task proofs (photo, note, tag), and moments (photo, late); replies to a photo (since Messages was removed the app writes no text-only chat or replies; existing replies are shown read-only) | Members post chat; sitters post photo messages and the shift's moment; proofs only via complete_task. Sender, role, time, and late are set by the database. Clients never edit or delete; a parent can remove a photo with remove_photo (the message stays, marked "Photo removed by …") |
message_reads | Which messages a user has seen | Each user, for themselves; private |
photo_saves | A parent's saved-photo album | Each parent, for themselves; private |
concept_flags | Concept only: the demo "child seems off" alert on a photo; nothing analyses photos | Parents set and clear; members read |
sitter_profiles | A sitter's public profile: display name, about, phone, availability, which of about/phone/availability parents may see, and their private "session update reminders" preference | Each user, for their own row only (create and edit; nobody deletes, account deletion removes it). Parents never read the table; they get only the shared fields through household_sitter_profiles |
removed_photos | Every Storage path a parent removed (internal) | Nobody directly; only remove_photo and remove_all_photos. Clients cannot read it |
Storage photos | Private bucket, 5 MB, JPEG/PNG/WebP, at <household_id>/<visit_id>/<uuid>.<jpg|png|webp> | Members upload into an open, in-window shift of their household (at most 2 photos per active task + 10 per shift); members read photos that were not removed; no overwrite |
create_householdhouseholds.child_namechild_age_labelcare_items_per_child_has_child20261106000100ensure_first_child20261105000000_carenest_several_families.sql lets a sitter
join more than one family space by invite; create_household still refuses anyone already in a
family space. RLS was already per family, so someone in two families reads both. The app shows
one at a time: js/active-family.js picks it (this tab's choice, then the last family chosen
on this device, then the family where they are a parent, then the first joined). Every query
filters on that family's id. Realtime subscriptions do too, except the task_checks and
shift_events listeners (no household column), which can only cause an extra reload of the
shown family. A family switcher appears at the top of each page and on the Profile page once
there is more than one. Deleting an account leaves every family it is in and deletes only
a family where it was the sole parent.households.home_address is optional (empty when not set) and only parents write it. It is
protected by the household's select policy, so only current members read it and a removed
sitter loses access as soon as their membership row is gone, including on a Realtime
subscription. A copy already on a sitter's screen or in their browser cannot be recalled.SECURITY DEFINER functions with
a fixed empty search_path. Creating a family space requires an explicit risk acknowledgement,
stored as households.risk_acknowledged_at.(now() at time zone 'utc')::date, which ignores the client-settable
session time zone, including PostgREST's Prefer: timezone), so a client cannot claim a
plan's far-off dates. A shift dated outside that window is read-only even if nobody ended
it: no check, no uncheck, no handoff-note edit, no reopen.end_visit, which records who ended it and when (one
timestamp, read under the visit's row lock, is used for visits.ended_at, the ended event,
and the return value). After that its task checks are frozen. end_visit also works on an
open shift outside the window, so a forgotten shift can still be closed for the record. The
handoff note stays editable after the end while the shift's date is in the window.reopen_visit (for example, if the sitter ended it by mistake), but only while its date is in
the window; otherwise visit_too_old, so old days cannot be backfilled. Reopening clears the
current end on visits so tasks can be checked again; nothing else is erased.shift_events (kind, who,
when), so the app can show "Ended 5:02pm by Bethany · reopened 5:10pm by Bethany". Members
read it; no client can insert, update, or delete it (no table or column write privilege);
only end_visit, reopen_visit, and the uncheck trigger (all SECURITY DEFINER, empty
search_path) write it. It is published to Realtime so the parent's page can alert them when
a shift is reopened. Append-only is enforced against members' direct writes; deleting a plan
(a parent) or a family space (delete_my_account) cascades its shifts and their history away.reopened
event and the check's checked_at (set by the database; clients cannot write it) is later
than the shift's first ended event. A check that raced the first end without any reopen is
not labelled.checked_at no later than the first ended
event), an unchecked event with the task and the member is appended. Unchecking a task that
was checked after the reopen, or before any end, is not logged. Checks removed because a task,
plan, shift, or family space was deleted are not unchecks and are not logged. If the task is
later deleted, its unchecked events stay with no task (task_id null).end_visit: a BEFORE INSERT trigger on task_checks locks the visit
(for share, which waits for end_visit's for update) and re-checks the end and the date
window for every signed-in insert, whatever path made it; complete_task also takes the same
lock. Two-session tests cover both RPC paths and a raw insert.care_logs entry to it: meal (an amount, a note, or both), bottle (ounces, more than
0 and at most 16, required), or care (a note, required); a note is at most 1000 characters.
An empty entry is refused by CHECK constraints (the app shows "No information provided"
instead of saving it), and a meal amount or ounces on the wrong category is refused too. Ounces
are numeric(3,1), so a value is rounded to 0.1 before the range check (the app rounds to 0.1
as well). A BEFORE INSERT trigger (stamp_care_log, SECURITY DEFINER, empty search_path)
requires a signed-in user (not_authenticated; the service role is refused too) who is a
member of the shift's household (visit_not_found: a shift of another family looks the same
as one that does not exist, as in complete_task), the shift's date in the window whether it
is open or ended, like the handoff note (visit_too_old), a task of the shift's plan
(task_not_found, as in complete_task; this is checked even if a task_checks row says
otherwise), and the task checked in that shift at that moment, read under a share lock that
serialises with a concurrent uncheck (task_not_completed); it sets the household, author,
role, and time whatever the client sent (those columns are not even insertable). Two-session
tests cover a log racing an uncheck in both orders. Append-only: members have no UPDATE or
DELETE privilege, and an edit is a new row under the same task: the app keeps every entry
visible under its task, oldest first, with later ones labelled "Update". The task's check and
its checked_at are never touched; a later uncheck leaves earlier logs in place. Members
read all logs of their household; anon nothing. Published to Realtime (the DELETE-event
limitation below applies: one random UUID per log removed with a family space).
delete_my_account cascades a sole parent's logs away; a departed sitter's logs stay without
their name.20261109000000_carenest_care_log_entries.sql). The Care Log
page's entries are care_logs rows with category note, no task, and a child. Every rule above
still applies: the same stamp_care_log trigger (replaced, with the task path unchanged)
requires a signed-in member of the shift's family space (not_authenticated,
visit_not_found) and the shift's date in the window, open or ended (visit_too_old; the same
window as a task log and the handoff note, so an end-of-session recap is allowed), and sets the
household, author, role, and time. It also checks the child: one of the shift's family space
(child_not_found, also enforced by the composite foreign key (child_id, household_id) ->
children (id, household_id)), required for a note (child_required), and copies the
child's name into child_name. Clients may insert child_id but not child_name. A CHECK
(care_logs_task_category) pairs the shapes: a note has no task and meal/bottle/care need
one, and a note needs a non-blank note (care_logs_not_empty). Deleting a child sets its
entries' child_id to null (only that column) and keeps the name, so past sessions stay
readable. The deployed client's task logs (no child) are accepted exactly as before, so the
migration can be pushed before the page ships. Members read every entry of their household;
anon nothing. Tests: tests/care-log-entries.test.mjs.
Decision record: extend care_logs instead of a new table, so one append-only log carries one
set of grants, one trigger, one Realtime publication, and one read query per shift (the
standard: one table per kind of record, with the rules in the database). Rejected: a parallel
session_entries table, which would copy the trigger, grants, and Realtime wiring and split the
session log across two reads. What breaks next: task_id is now nullable, so any new code that
assumes a log has a task must check category; and a family space with no children cannot
have entries. Reversal: a later migration can move note rows into their own table (insert
... select, then delete them and restore task_id not null and the old CHECKs); no client
depends on the shared table beyond the care_logs select in js/shift-data.js.task_title,
task_time_of_day, task_detail, task_location, task_care_note), filled by the database.
Frontend: past shifts render the snapshot, not the live task. Archived tasks are frozen
(parents cannot edit them) and archived_at is stamped by the database and never changes back.delete_my_account
removing a sole parent's whole family space. The service role can still delete visits.delete_my_account deletes every family space where you are the only parent (cascading all
care info, contacts, plans, tasks, shifts, checks, and invites), removes your other
memberships, and deletes your sign-in account. When a caretaker deletes their account, checks,
notes, and shift-history entries they made stay in the family's history without their name
(the user column becomes null, shown as a former member). Storage objects do not cascade, so a
sole parent's app first calls remove_all_photos (every photo in those family spaces' folders,
including ones of deleted shifts, is marked removed and its path returned), deletes the returned
objects with the Storage API, then calls delete_my_account, which refuses with
photos_remaining while any photo is still there (e.g. a sitter uploaded one meanwhile; the
app says to try again and repeats both steps).photos bucket is private (no public URLs, and no signed download or upload
URLs for anyone: the app uses authenticated downloads and uploads only). Members of the
household in the path's first folder read; a member uploads only into an open shift of their household whose
date is in the start window; nobody overwrites or moves a photo. A task is completed only with
complete_task(visit, task, photo_path, note, tag), which writes the check and the proof
message together; proofs, moments, and photo messages re-check the shift under a share lock on
the visit, so they cannot slip in after a concurrent end_visit. Chat stays open after a shift ends.
Caretakers must attach a photo to complete a task; parents may check off without one (shown as
done by them, no proof message). Each shift gets one moment prompt (visits.moment_at, chosen
by the database at a random time from 30 minutes after the start to 30 minutes before the usual
end, using the usual shift length parsed from the plan's schedule label such as "3:00pm -
7:00pm", "3-7pm", "9am to 1pm", "15:00–19:00", or "9-5", which means 9am-5pm; "10pm-6am" is
overnight; 30 minutes to 3 hours when the label is not understood, including labels with
non-ASCII digits such as "3-7pm": an odd label never blocks starting a shift). A parent can call
trigger_moment_now(visit) on an open, in-window shift with no moment yet to move it to 1-5
minutes from now (demo). Only a sitter posts the moment, once; its photo must have been
uploaded at or after the prompt (Storage's server-set created_at), otherwise
moment_photo_too_early; it is late if posted more than 2 minutes after the prompt. The app
also refuses an uploaded (not camera-captured) moment photo whose own capture time (EXIF) is
more than 2 minutes before the prompt; photos with no capture time are accepted.photo_limit_reached (the app shows "Photo limit reached for this shift."); deleting an unused
photo frees room. A sitter cannot delete a photo a message uses (a proof, moment, or photo
message), so a used photo keeps its slot until a parent removes and deletes it. Never at a path
a message uses or a parent removed.remove_photo(message)
clears the message's photo, records photo_removed_by/photo_removed_at ("Photo removed by
Mary"; the message, its task check, and the history stay), records the path in
removed_photos, deletes every parent's save of it and its concept flag, and returns the path;
the client then deletes the object with the Storage API. From the moment of removal, before the
object is deleted, nobody can download it or sign a URL for it, nobody can upload to that path
again, and it cannot be attached to a proof, moment, or photo message again (photo_removed). Saves and flags
cannot be added to a removed photo, including one racing the removal (a trigger locks the
message and re-checks; photo_removed). Someone outside the family gets the same refusal
whether or not the message has a photo.chat row with photo_path and visit_id (the shift); the body may be empty.
Only the sitter posts one, only into an open shift dated in the window, with a photo they
uploaded to that shift's folder and no message uses yet (not_caretaker, visit_ended,
visit_too_old, photo_not_found, photo_removed); it is never a reply. A parent removes its
photo with remove_photo like any other (the message stays as a tombstone). A shift takes at
most 20 live photo messages (stamp_message, serialized per shift with an advisory lock;
a removed one frees room); the 21st fails with photo_limit_reached. Saves and concept
flags remain for proofs and moments only. The Gallery loads the newest 500 photos.storage.operation()). Database
cascades do not remove Storage objects, so deleting an account is three client steps:
remove_all_photos(): in every family space where you are the only parent (the ones
delete_my_account() deletes), removes every photo exactly as remove_photo does, and
returns every Storage path in those family spaces' shift folders (removed and unused). For
a sitter, or a parent with a co-parent, it does nothing and returns an empty list.storage.from('photos').remove(paths) with the returned list.delete_my_account().
If the client stops after step 1, the family space stays, with every photo marked "Photo
removed by …" and no message pointing at a missing photo.carenest-photo-cleanup, at :17)
deletes them through the Storage API, together with removed photos whose client delete failed
and uploads no message uses once their shift can no longer use them (its date is outside
yesterday–tomorrow UTC) and they are over 24 hours old (photo_orphans() lists them; up to
1000 per run). A second job (carenest-photo-cleanup-check, at :47) fails when Storage refused
a delete; refused paths back off (up to a day) so newer orphans still go.
It needs the one-time Vault setup under "Deploying to a hosted Supabase project"; locally it
fails every hour with photo_cleanup_not_configured unless you add those secrets.schedule.html saves a
schedule_requests row; the family's parents see it on their schedule page and on parent.html
(live, Realtime) and can dismiss it. Adding a schedule (appointment) for that date clears it
automatically. Only caretakers can ask, only parents can dismiss, nobody can ask in someone
else's name. A request can be for yesterday (UTC) up to a year ahead, and not for a day that
already has a session. In-app only: no email or push notification is sent.task_checks also receives the primary keys (two random
UUIDs, no content) of unchecks in other family spaces. The same applies to visits and
shift_events rows removed when a family space or plan is deleted (one random UUID each), and
to messages and care_logs (one random UUID each; clients never delete messages or care
logs, so only cascades emit them), to day_notes (one random UUID per deleted note), and to
contacts, care_items, plans, tasks, households, children, appointments, and
schedule_requests (one random UUID per deleted row; clients never delete schedule requests,
so only cascades emit them; parents delete appointments, and the app refetches only when the
deleted id is one it is showing;
with RLS on, a DELETE event carries only the primary key whatever the replica identity, so no
content is sent; it stays default because FULL adds write-ahead-log volume for no benefit).
tasks go only by cascade from a plan or family-space delete, so those cascades still emit task
ids to any subscriber of the table, though the app itself does not listen for task deletes.
Clients must ignore events for shifts they are not showing.