# NOVACares — novacares.my (PRODUCTION) · repo brain

This repo IS the live site for ~137 families of the NOVA cohort (Form 2 2025 → Form 5 2029) at
MRSM Langkawi. **Every deploy goes to production.** A masked staging environment exists (below).

NOVACares is an **independent parent community — never imply it is the PTA/PIBG or an official
school body**. Tagline "Supporting. Uplifting. Together." BM-first copy.

**Start every session by reading `docs/context/STATE.md`** (current state, environments, the
working loop, definitions, open threads). The long ledgers in `docs/context/*.md` are the full
history — grep them when a decision's "why" matters. **After shipping anything, append a dated
line to the changelog in `docs/context/STATE.md`** — that file is the memory that travels
between devices and cloud sessions; nothing on any one machine is.

## Stack & hard rules

- Plain PHP 8.2 (ea-php82), MySQL 8, **ZERO Composer/npm dependencies** — everything is hand-
  rolled (SMTP, ToyyibPay, Web Push crypto, XLSX reader, Anthropic + Telegram clients). Never
  introduce a package or CDN script.
- Front controller `public/index.php` → `modules` registry → `app/modules/<id>/index.php`, plus
  the permanent legacy-URL compat layer (old live-site URLs 301/307) — never remove those routes;
  they live in parents' WhatsApp forever.
- `public/assets/css/tokens.css` is the ONLY home for hex colours/fonts/radii, enforced by
  `sh setup/check-tokens.sh` (scans PHP inline `style=""` and JS strings too). Exceptions:
  `app/lib/emailtpl.php`, `.svg`. Page-local CSS uses `var(--*)` only.
- Migrations: idempotent, `information_schema`-guarded PREPARE/EXECUTE (`setup/migrate-v*.sql`).
  v36 is applied on production; **next free number ≥ v37**. Never `ADD COLUMN IF NOT EXISTS`.
  Live-drift columns with no DDL (`trips.pb_type`, `last_minute_hours`, `last_minute_manual`):
  read via `$row['pb_type'] ?? ''`, never name them in SQL.
- Admin pages: docblock ending in an `Access:` line; `viewerIsCommittee()` (+ `viewerHasCommittee
  ('logistics')` for transport) gate; `csrfOk()` FIRST on POST (it reads `$_POST` only — fetch
  must send form-encoded bodies); PRG with `?msg=&type=`; every echo through `h()`; one inline
  `<script>` per page with a 2-letter prefix and no PHP inside it; `$__tpl='app'` + layout.php.
- **Definitions (house-wide, decided 6 Sep 2026):** a *booking* = `payment_status IN ('Paid',
  'Unpaid')` (Failed/Cancelled/Expired are not bookings). *Revenue* = `SUM(total_amount) WHERE
  paid_at IS NOT NULL` — never by status, never from `financial_entries`. *Capacity* =
  `SUM(vehicles.capacity)` per trip = provisioned supply (fill can exceed 100%). *Occupancy* =
  paid seat-legs on a vehicle, read ONLY via `tpOccupancy()`/`tpOccupancyMap()`. A Return
  booking occupies seats on BOTH slots (`slot_id` and `slot_id_2`); leg 1 ↔ `vehicle_id`/
  `boarded_at`, leg 2 ↔ `vehicle_id_2`/`boarded_at_2` — one `students` row serves both legs.
  `financial_entries` is append-only (reverse, never edit/delete).
- Capability tokens (SHARE_LINK_SECRET HMAC, carpool DB tokens, VAPID keys) are NEVER rotated or
  re-hashed on production — the links/subscriptions live in inboxes and phones forever. (The
  Telegram bot token is the one secret that CAN be rotated: BotFather `/revoke`, paste the new one.)
- Telegram: outbound (`tgSend`, alerts, 📣 notices) and inbound (`tgRunInbox` → `tg_inbox` +
  private `tg_media` store → `/admin/telegram-inbox.php`). The bot reads ONE configured chat,
  channel posts only; inbox photos never reach the public site except through T3's attested
  publish. Runbook: `setup/TELEGRAM-GO-LIVE.md`.
- **Verification ritual before any commit:** `python3 tools/phpbal.py <touched .php>` (paren/
  brace balance; naive checkers false-positive on heredocs), `node --check` on any inline JS
  (extract it; it must contain no `<?`), `sh setup/check-tokens.sh`. `tools/nova check` runs
  the first and last. Then an adversarial review pass (find → independently verify → fix only
  confirmed findings) for anything beyond a one-liner.

## Environments

| | Production | Staging |
|---|---|---|
| URL | https://novacares.my | https://dev.novacares.my (sends `X-Robots-Tag: noindex`) |
| Tree | `/home/dalecarn/public_html/addons/novacares3/` (deploy copy) | `/home/dalecarn/repos/novacares-staging/` (git clone served in place; docroot = its `public/`) |
| Config | `config/config.production.php` (server only, 0600) | `config/config.staging.php` (server only; NOVA_ENV=dev, ToyyibPay sandbox, mail catch-all, blank Telegram, own VAPID/secrets) |
| DB | `dalecarn_novacares` | `dalecarn_staging` (masked copy; has `_staging_marker`) |
| Update | cPanel Git `novacares` → **Update from Remote → Deploy HEAD Commit** (`.cpanel.yml` copies `app/ public/ setup/` only) | cPanel Git `novacares-staging` → **Update from Remote** only (`.cpanel.yml` refuses to deploy from it) |
| Data refresh | — | one-shot cron `/bin/sh /home/dalecarn/repos/novacares-staging/setup/sync-staging.sh` (dump prod read-only → import → `setup/staging-mask.sql` → verify). Guards S0–S8; never nightly |
| Admin preview | real login only | `?as=committee&key=<NOVA_PREVIEW_KEY>` arms the dev preview (key lives in the staging config) |

`dalecarn_logistics` = frozen pre-cutover archive, never dropped (7-year financial retention),
never a write target. Never `git clone` into a docroot. Directory Privacy stays OFF on the
staging docroot (it writes into the tracked `public/.htaccess` and breaks pulls).

## Deploying & operating (no SSH, no cPanel API — Imunify360 blocks it)

- The only automation is GitHub → cPanel Git. `tools/nova ship "msg"` commits + pushes;
  the human taps Update/Deploy in cPanel (phone browser is fine); `tools/nova verify` probes
  the live battery (`/`, `/apps/*`, `/stories`, `/payment-callback.php` must never be 4xx/5xx,
  one legacy 301, dev-marker leak check). cPanel shows a cosmetic red "Error:" with no text
  after every successful deploy — verify with the battery, never trust the banner either way.
- Anything that must run ON the server (migrations via `setup/sql-run.php`, the staging sync)
  runs through a **one-shot cPanel cron** (`tools/nova migrate-help <file>` prints the line):
  add → wait ~2 min → delete → read the log in File Manager. Cron's bare `php` is php-cgi; use
  `/usr/local/bin/ea-php82`. cPanel's Command box takes the command ONLY — never paste the
  `0 8 * * *` schedule into it (the cutover cron did, and silently never ran until 6 Sep). Migrations can equally be imported in phpMyAdmin (select the DB
  in the left sidebar first).
- Server logs (File Manager): `storage/logs/php-error.log`, `mail.log` (a `[DEV → …]` line on
  production means the config regressed: P0), `push-notifications.log` (counts only),
  `transport.log`, `sync-staging.log` (staging).
- Rollback = a revert commit + the same two taps.

## Privacy (non-negotiable)

The subjects are 137 minors. Their roster/PII lives in the DATABASE — never in this repo, never
in logs, never in `?msg=` flash text (URLs are logged), never in AI prompts or chat transcripts.
`parents.notification_email` is self-set and unverified: **never a match key** (status pages and
push target the OTP-verified login email only). Photos of children: see the album spec
(`docs/context/project_nova_accounts_verification.md`, OneDrive `NOVACARES_ALBUM_VERIFICATION_
SPEC.md`) — no face recognition, no blur, parents-only tier, consent before publish.

## Working from a cloud session (claude.ai/code)

Same rules, same tools: the repo is the whole brain (this file + `docs/context/`). There is no
server access from anywhere except HTTPS probes of the two sites — make sure the environment's
network access allows `novacares.my` and `dev.novacares.my`, or skip the probe and ask the
human to run `tools/nova verify`. Secrets never enter the repo or a session: config files stay
on the server; the staging kit stays on the Mac. Deploys are always the human's taps.
