Start here
Everything a new session needs before touching anything. Read this, then docs/register.md for what is built, then docs/working-with-agents.md for how the last session went wrong.
The repo
/Users/alex/projects/atlas-portal
github.com/alex-atlas-media/atlas-portal
Next.js 16 App Router, React 19, Tailwind 4, TypeScript strict, Node 22. Postgres 17 on Supabase, Singapore region. Two Supabase projects — dev and prod are separate databases, deliberately: a bad dev migration must not be able to reach live client data.
The two environments
| Production | Dev | |
|---|---|---|
| URL | app.atlasconsulting.tech | dev.atlasconsulting.tech |
| Branch | main | dev |
| Directory on the box | /srv/atlas-portal | /srv/atlas-portal-dev |
| Compose file | docker-compose.yml | docker-compose.dev.yml |
| Login | enforced | open — ATLAS_OPEN_ACCESS=true, Alex's decision |
Almost all work happens on dev. Production is many commits behind and is promoted deliberately, never by habit.
The box is a DigitalOcean droplet, 209.97.171.176, reached with ssh -i ~/.ssh/atlas_portal root@209.97.171.176. Docker Compose behind Caddy.
The plaindocker-compose.ymlin the dev directory is production's. Using it there starts a second set of containers against the wrong database.COMPOSE_FILEis pinned for this reason.
Secrets
They live in .env on the droplet, one per environment. Nothing real is ever committed; .env.example documents the names.
# Set one, in both environments, and verify it took. Reads stdin so it never hits shell history.
./scripts/set-secret.sh META_SYSTEM_USER_TOKEN # paste, Enter, Ctrl-D
# List which names are set (values not printed)
ssh -i ~/.ssh/atlas_portal root@209.97.171.176 \
'sed -n "s/^\([A-Z][A-Z0-9_]*\)=.*/\1/p" /srv/atlas-portal-dev/.env | sort'
.env.localpoints at the real dev database. It readspostgresql://postgres.kmxipgqwtvemezucecyh@aws-0-ap-southeast-1.pooler.supabase.com, which is Supabase dev — the same datadev.atlasconsulting.techserves, not a local copy. This file used to say it pointed at localhost and was not the real database. Anything you run locally, includingnpm run devand any script, reads and writes live dev client data.
Environments, 22 Sep 2026
Prod (app.atlasconsulting.tech, /srv/atlas-portal, branch main) and dev (dev.atlasconsulting.tech, /srv/atlas-portal-dev, branch dev) run the same code since the 22 Sep promotion; main is fast-forwarded from dev. Prod shows only the sections in PORTAL_MODULES (currently /pipeline,/finance); unset on dev means everything. To release a module on prod, add its section href to that variable in prod's .env and redeploy.
The commands that matter
./scripts/deploy.sh dev # rebuild and restart the containers
./scripts/migrate.sh dev # apply new migrations — PUSH FIRST, see below
./scripts/qa.sh dev # everything that must pass before a change is done
./scripts/refresh.sh dev # Meta spend, leads, daily rows, reconciliation — no model, free
./scripts/nightly.sh dev # WhatsApp, Drive notes, extraction, supersession — costs money
npm test # vitest, 630 tests
npx tsc --noEmit && npm run lint
One build at a time, and let the box settle first. The droplet is 4 GB with no swap; a Next.js build takes 1.1 GB and most of both cores. On 21 Sep 2026 a second deploy started within a minute of the first finishing and, together with the 3-hourly refresh.sh, put the load average at 34 and froze dev for about ten minutes. Wait until uptime shows load under 2 before the next deploy.sh, and never deploy in the five minutes either side of a refresh run (every 3h) or the nightly (04:10 SGT).
migrate.sh resets the box to origin/$BRANCH before it looks for migrations. A migration that has not been pushed does not exist as far as it is concerned. Always: commit → push → migrate.
Scheduled work
On the droplet's crontab, all against dev:
| When | What |
|---|---|
| 03:20 SGT | backup.sh — dump, restore into a throwaway container, compare row counts |
| 04:10 SGT | nightly.sh — ingestion and extraction. Costs a few dollars a night |
| Every 3h | refresh.sh — Meta reads and the reconciliation. Free |
What is connected, and what it can do
| Source | State | Write access |
|---|---|---|
| Meta ads | 13 client accounts, 495 monthly rows, daily rows since May | none — ads_read, no ads_management |
| Meta Pages / Instagram | 14 Pages, 15 IG accounts, 6 months organic | none — ANALYZE / MANAGE_LEADS only |
| Meta leads | 903 leads with name, email, phone, custom fields | none |
| WhatsApp (Wassenger) | 35 client groups, 3,891 messages | cannot send — enforced in code |
| Google Drive | 5 team Drives, domain-wide delegation | read-only |
| ClickUp | 733 tasks, 2,035 comments, 1,437 channel messages | GET only — hard-coded, tested |
| Higgsfield | image and video generation | write, by design |
| Anthropic | extraction, strategy, copy, the Brain | — |
Each read-only guarantee is structural and tested, not a policy someone remembers: src/lib/clickup/client.test.ts, purpose-rule.test.ts, and the Meta token's scope list.
Before changing anything
CLAUDE.md— the rules table, one line each, linking to the file that owns each concerndocs/register.md— every ask and its state. Entries 228–246 are the most recent workdocs/sop/— the specifics that each cost an hour to learn: Meta access, campaign namingdocs/working-with-agents.md— why the last long session produced five faults, and the rule that followed
The four standing constraints
- Read-only where it says read-only. Not by restraint — the capability was never issued.
- Recruitment never averages into client results. Enforced across ten services by a test.
- Every number states its window and purpose. Three of five past faults were figures that were arithmetically correct and did not say what they were.
- Nothing guessed is written. A domain, client match or campaign purpose that cannot be evidenced stays empty and visible.