Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 39 additions & 9 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,21 +105,43 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
AUTH_BASE_URL=http://localhost:3000
AUTH_ALLOWED_EMAIL_DOMAIN=cornell.edu
AUTH_STAFF_EMAILS=you@cornell.edu # gives you the Refresh catalogue button
AUTH_SUPER_ADMIN_EMAILS=you@cornell.edu # the floor — see below
```

`AUTH_SECRET` alone is enough for sessions; the two `GOOGLE_*` variables are
what make *starting* one possible. With them unset, `/api/auth/sign-in/social`
answers 503 and the header says sign-in is not set up here.

`AUTH_SUPER_ADMIN_EMAILS` is a **floor, not a roster**. Everyone else's role is
the `user.role` column, changed on `/admin/users`; an address listed here is
created as `super_admin` on first sign-in and stays one whatever its row says.
It is the only way the first super admin comes to exist (no user row exists
until somebody signs in) and the reason the lab cannot lock itself out.
`AUTH_STAFF_EMAILS` and `AUTH_ADMIN_EMAILS` were removed in Phase 4 — nothing
reads them, so delete them from the deployment's environment rather than
leaving a list that grants nothing.

Tickets and projects now record the **verified session** instead of a typed name. Anonymous
browsing and chat keep working — sign-in unlocks, it does not gate the front door.

## Stage 4 · Backups locally (optional)
## Stage 4 · The nightly job locally (optional)

Cron does not run locally. Trigger it by hand:

```bash
curl -H "x-admin-secret: $ADMIN_REVALIDATE_SECRET" \
http://localhost:3000/api/admin/backup
http://localhost:3000/api/cron/daily
```

It exports every Postgres table to a private blob and sweeps photos that were uploaded but
never attached to anything. It needs `BLOB_READ_WRITE_TOKEN` and refuses without it — the
old `/api/admin/backup` route it replaces dumped Notion and is no longer scheduled.

Two tables are held out of the file on purpose: `session` and `verification` are sign-in
credentials, not records, and the Google tokens on `account` are blanked. A backup is
something you might email to yourself at 2am; it must not double as a way to sign in as
somebody. People, roles and bans are all still in there.

---

# Part 2 — On Vercel
Expand All @@ -145,8 +167,17 @@ ADMIN_REVALIDATE_SECRET cache invalidation
**Sign-in** — same as Stage 3, but `AUTH_BASE_URL=https://<your-domain>` and the Google
redirect URI updated to match.

**Backups** — link a Vercel Blob store (sets `BLOB_READ_WRITE_TOKEN`), then set
`CRON_SECRET`. The cron itself is already in `vercel.json`, nightly at 07:17.
**Blob store** — link one (it sets `BLOB_READ_WRITE_TOKEN`). It carries **both** jobs now:
every photo a student uploads through the chat or the project form, and the nightly backup.
Without it the site still runs — uploads say so and the catalogue is unaffected — but
nothing is backed up and no photo can be attached.

**Backups** — with the store linked, set `CRON_SECRET`. The cron is already in
`vercel.json`, nightly at 07:17, pointing at `/api/cron/daily`.

**`LAB_TIMEZONE`** — optional, defaults to `America/New_York`. It decides the date on a
maintenance ticket; a function running in UTC would otherwise date an evening report
tomorrow. Set it before the first ticket is filed, or leave it to the default.

**Optional** — `NOTION_DB_PROJECTS`, `UPSTASH_REDIS_REST_*` (rate limits enforced across
instances rather than per-process), `MCP_TOKEN` (also the switch that exposes write tools
Expand Down Expand Up @@ -188,10 +219,9 @@ Send alerts to a shared address, never one person.
| | Blocks |
|---|---|
| Google OAuth client | Sign-in anywhere |
| `AUTH_STAFF_EMAILS` / `AUTH_ADMIN_EMAILS` | Staff features — **these two lists are the entire role system**; there is no user database |
| Notion: Flags `status` → `New` option | Corrections, silently |
| Notion: Projects DB + `published` checkbox | The gallery |
| Vercel Blob + `CRON_SECRET` | Backups — **there is currently no backup at all** |
| `AUTH_SUPER_ADMIN_EMAILS` | The first super admin, and therefore **every role**: nobody can be promoted until somebody can reach `/admin/users`. Roles live in `user.role` now; `AUTH_STAFF_EMAILS` / `AUTH_ADMIN_EMAILS` are retired and should be deleted from the environment |
| Vercel Blob store | **Photo uploads** (chat, maintenance, projects) and backups. Without it uploads refuse with a translated message rather than failing silently |
| Vercel Blob + `CRON_SECRET` | The nightly backup — **there is currently no backup at all** |
| Inference spend limit | Nothing, until it does |
| Uptime monitor | Nothing, until something breaks quietly |

Expand Down
59 changes: 37 additions & 22 deletions docs/handover.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,12 @@ for what remains to verify before production traffic goes through it.

### Handle a maintenance ticket

Tickets from the assistant land in **Maintenance_Logs**, with photos if the student attached
any. Work them in Notion: set `status` to `In Progress`, then `Resolved`, and fill in
`resolution`.
Tickets from the assistant land in the **`maintenance_logs`** table in Postgres, with photos
if the student attached any. Corrections students report land in **`feedback`**, and project
submissions in **`projects`** (unpublished until staff publish them). None of the three go
to Notion any more, and the admin queues that work them are a later phase — until then,
working a ticket (`status` → `in_progress` → `resolved`, plus `resolution`) needs a
developer **[dev]**.

Nothing in the app enforces this. **A ticket queue nobody reads is worse than no ticket
queue** — students stop reporting after a couple of unanswered reports. Decide who checks
Expand All @@ -157,21 +160,26 @@ Environment variables in Vercel, no code change: `NEXT_PUBLIC_SITE_NAME`,

### Check the nightly backup is still running

**Every night at 07:17 UTC (about 03:17 New York) the site backs up Notion.** Vercel Cron
calls `/api/admin/backup`, which reads every Notion database and writes one file to private
Vercel Blob storage as `backups/YYYY-MM-DD.json`. Files older than **30 days** are deleted
by the same job, so the store holds roughly a month at any time.
**Every night at 07:17 UTC (about 03:17 New York) the site backs itself up.** Vercel Cron
calls `/api/cron/daily`, which exports **every Postgres table** and writes one file to
private Vercel Blob storage as `backups/YYYY-MM-DD.json`. Files older than **30 days** are
deleted by the same job, so the store holds roughly a month at any time. The same run also
deletes photos that were uploaded but never attached to anything within 24 hours.

**This is the only copy of the Notion data outside Notion.** Before it existed, one deleted
database meant ~100 machines of staff work was gone for good.
**This is the only copy of the data outside Neon.** Before it existed, one deleted database
meant ~100 machines of staff work was gone for good.

> The job used to dump Notion at `/api/admin/backup`. Postgres is the source of truth now,
> so the file holds database rows and its `source` field reads `postgres`. A file written
> before September 2026 holds raw Notion pages instead.

Three settings in Vercel make it work, and it does nothing without all three:

| Setting | Where | What it is |
|---|---|---|
| A **Blob store** linked to the project | Vercel → Storage | Sets `BLOB_READ_WRITE_TOKEN` automatically |
| A **Blob store** linked to the project | Vercel → Storage | Sets `BLOB_READ_WRITE_TOKEN` automatically. **Also required for photo uploads** — with no store, the chat and the project form say photo uploads are unavailable instead of failing silently |
| `CRON_SECRET` | Vercel env vars | Vercel sends it so the route knows the nightly call is genuine |
| `ADMIN_REVALIDATE_SECRET` | Vercel env vars | Lets a person trigger a backup by hand (same secret as §4) |
| `ADMIN_REVALIDATE_SECRET` | Vercel env vars | Lets a person trigger the job by hand (same secret as §4) |

**How to check it, once a month:** Vercel dashboard → your project → **Cron Jobs**. A green
run means a file was written. **A red run means the backup did not happen** — the route
Expand All @@ -181,21 +189,28 @@ is discovered on the day you need it. The failure reason is in the run's log.
To run one by hand, or to confirm it works after changing anything:

```
GET https://<your-site>/api/admin/backup
GET https://<your-site>/api/cron/daily
Header: x-admin-secret: <ADMIN_REVALIDATE_SECRET>
```

It answers with the file it wrote, how many rows came from each database, and which old
files it deleted. Anything other than `200` is a real failure.
It answers with the file it wrote, how many rows came from each table, which old files it
deleted, and what the orphaned-photo sweep removed. Anything other than `200` is a real
failure, and the body names which stage broke.

> [!WARNING]
> **The backup contains student names and email addresses** from Maintenance_Logs. It is
> written to *private* blob storage and must stay that way — never make the store public,
> never share a download link, and list it in whatever data inventory the university keeps.

**To restore:** download the file from Vercel → Storage → Blob, and re-import the affected
database. The file holds the raw Notion rows, so a person can read it and rebuild from it.
There is no automated restore, on purpose — it is far more work than the failure justifies.
> **The backup contains student names and email addresses** from `maintenance_logs` and
> `feedback`. It is written to *private* blob storage and must stay that way — never make
> the store public, never share a download link, and list it in whatever data inventory the
> university keeps.
>
> It deliberately contains **no sign-in credentials**: the `session` and `verification`
> tables are skipped and the Google tokens on `account` are blanked, so somebody holding a
> backup file cannot use it to sign in as anybody. People, their roles and their bans *are*
> in it, because that is the state a restore most needs to get right.

**To restore:** download the file from Vercel → Storage → Blob. The file holds the table
rows as JSON, so a person can read it and rebuild from it. There is no automated restore, on
purpose — it is far more work than the failure justifies.
**[dev]** for anything beyond reading the file.

---
Expand Down Expand Up @@ -225,7 +240,7 @@ effect.
| Vercel logs | `DbUnavailableError` (Postgres unreachable) | Whenever the catalogue looks odd |
| Vercel → Storage | The Neon database is reachable | Whenever the catalogue looks odd |
| Vercel → Cron Jobs | The nightly backup ran green | Monthly — see §3 |
| Notion: Maintenance_Logs | Open tickets | Per §3 |
| Postgres: `maintenance_logs` | Open tickets | Per §3 |

**The one alert that matters: an Anthropic spend threshold.** Everything else is
recoverable; an unbounded bill is not.
Expand Down
Loading