Admins configure one mail server; each staff member gets their own mailbox
login. Read, reply and compose against real IMAP/SMTP.
IMAP and SMTP are raw TCP, so none of this can run in a browser — every
operation is a TanStack Start server function. mail.functions.ts ships to
the client bundle, so imapflow/nodemailer/mailparser and the crypto
helpers are imported inside handlers, never at the top level. Verified
that Nitro inlines all three into .output/server/_libs, since the Docker
runner stage copies only .output and has no node_modules.
Note this ties the app to the Node deployment: the default local build
targets Cloudflare Workers, which cannot open IMAP sockets.
Credential handling, since a mailbox password grants full read and send
access to someone's mail:
- user_mailboxes has RLS enabled, no policies, and SELECT revoked from
anon and authenticated. Verified: teacher and admin both see zero rows
and no ciphertext; only service_role can read it. The revoke is belt and
braces — Supabase's default privileges had granted SELECT, leaving the
table one stray policy away from leaking.
- Passwords are sealed with AES-256-GCM using MAIL_CRED_KEY from
.env.secret, so a database dump alone opens nothing. GCM also makes
tampering fail the auth tag instead of decrypting to garbage.
- Provisioning verifies credentials against the live IMAP server before
storing them, so typos surface at setup rather than as a broken inbox.
Message bodies render as plain text; sender HTML is never injected, which
would execute sender-controlled markup and leak read receipts via
tracking pixels.
Mailboxes are limited to admins and teachers. Students are excluded
deliberately — external mail for minors carries archiving, monitoring and
consent obligations that should be chosen, not inherited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The database already permitted all three (forms admin manage is FOR ALL);
this was purely a missing UI. Create and edit now share one editor, since
the only differences are the initial values and insert vs update.
Three hazards in the existing data model shaped this rather than just
adding buttons:
- form_responses.form_id is ON DELETE CASCADE, so deleting a form
permanently destroys its submissions. The confirmation names the
response count and points to deactivating instead. Verified: deleting a
form with two responses leaves zero.
- The list filtered active = true for everyone, so deactivating a form
hid it from the only people who could reactivate it. Admins now see
inactive forms with a badge; families still see only active ones.
- Responses key their answers by field *label* in data_json, so renaming
a field strands existing answers under the old key. The editor warns
when the form already has responses. Deliberately not migrating old
answers — guessing which old label maps to which new one would risk
silently rewriting submitted data.
Also strips fields with blank labels on save, shows a response count per
form, and gives the response list an empty state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three documents, all print-styled rather than generated server-side: no
PDF dependency is added, and "Save as PDF" in the browser produces the
file. @media print in styles.css strips the app chrome (.no-print) and
breaks each student onto its own sheet (.print-page).
- Report card: weighted category breakdown, overall percent and letter
from the existing gradebook config, plus an attendance summary.
- Academic progress report: the same, plus assignment-level detail —
a mid-term report is only actionable with the underlying work listed.
- 504 / IEP progress report: each goal's baseline, target, status and
progress entries within the period, for the quarterly report IDEA
requires. RLS keeps it to the case manager, admins and parents.
- Invoice, reachable per student from the tuition ledger: opening
balance carried forward, itemised activity, and balance due.
Grade maths is reused from lib/grades and the class grading config from
useEffectiveConfig, so report cards cannot drift from the gradebook.
The "Email to parents" button opens a prefilled mailto: draft — the same
mechanism the intake-link share already uses. It gathers addresses from
intake guardians and linked parent accounts. mailto cannot attach a file,
so the itemisation goes in the body as text; sending a real attachment
would need a transactional email provider and an API key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a per-student daily rate (students.daily_tuition_cents) and a trigger
that writes a tuition charge when a student is marked present or late.
Idempotency is the crux. The attendance UI upserts on (student_id, date)
and teachers toggle a status freely — present, absent, present again. A
naive "insert a charge on attendance" trigger bills the family once per
click. So each auto-charge is bound to the attendance row that caused it
via ledger_entries.attendance_id (UNIQUE), which turns the write into an
upsert, lets a change back to absent delete the charge, and cascades the
charge away if the attendance record is deleted.
Manual entries keep attendance_id NULL — Postgres allows unlimited NULLs
in a unique index — so hand-entered charges are untouched and the UI can
tell auto from manual.
A NULL rate (the default) means never auto-charge, so nothing begins
billing until a rate is deliberately set on a student. Existing
attendance is not backfilled: retroactively generating charges against
families' balances should be an explicit decision, not a side effect of
deploying a migration.
The trigger is SECURITY DEFINER because the writer is a teacher marking
attendance while ledger_entries is admin-write under RLS; teachers gain
no general ledger access, only this fixed attendance-derived write.
Verified against the live schema across all eight paths: charge on
present, reversal on absent, no duplicate on re-mark, late billable,
excused free, no rate means no charge, rate change on re-save, and
cascade on attendance delete.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The server fronts everything with Caddy (container root-caddy-1), has no
Dockge and no Traefik, and keeps compose stacks in /docker — not
/opt/stacks. Every traefik.* label in this compose file was inert, so the
container would build and run while never being routed.
Join the external `web` network Caddy is on so it can reach the container
by name (info-share-spot:3000) and publish no host port. Routing now
lives in /root/Caddyfile on the server.
Rewrite DEPLOY.md against the real topology, including the Caddyfile
validate-before-reload step — a bad Caddyfile takes down every site on
the box, and Caddy serves a stale in-memory config until something
reloads it, which hides the breakage until the next restart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Plans, accommodations, related services, annual goals with progress
monitoring, meetings/team, and signed documents — plus a compliance
list and dashboard alerts for annual-review and triennial re-evaluation
dates (overdue in red, due-within-30-days in amber).
Access is tiered because special-education records are need-to-know
under FERPA:
FULL admin, the plan's case manager, the student's parents
IMPL the above, plus any teacher of the student — accommodations
and services only, never eligibility or meeting notes
RLS is row-level and every app role is the same Postgres role
(`authenticated`), so column grants cannot separate the tiers. The
split is therefore physical: confidential fields live in plan_details,
plan_goals, plan_meetings and plan_documents rather than as columns on
student_plans.
The UI asks the database which tier applies via the same predicates the
policies use (can_view_plan_full / can_edit_plan) instead of re-deriving
the rules client-side.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Safari doesn't reliably fire onChange for native date pickers, so read dob/
start/agreement dates directly from the input refs when saving.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Attendance filter, gradebook assignment date, and tuition entry date now use
defaultValue (with a remount key where the field resets after submit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>