# Deploying Plantoo_tech

Handover instructions. Written 2026-09-03 for T17; the environment-specific values are marked
`«fill in»` rather than guessed, because getting them wrong is worse than leaving them blank.

## What exists today

| Environment | State |
|---|---|
| CI (GitHub Actions, `.github/workflows/ci.yml`) | **Real.** Runs the PHPUnit suite against MySQL 8 + MongoDB 7 service containers on every push and PR to `main`, plus an `env-diff` job that fails the build on an undocumented variable |
| Testing / Staging | **Real**, driven by an external deploy tool (`plantoo_tech_testing`, `plantoo_tech_staging`), not by this repo |
| `.github/workflows/staging-deploy.yml` | **A deliberate stub.** Gated behind `vars.STAGING_DEPLOY_ENABLED == 'true'`; its deploy and smoke steps echo and exit. Wire it or delete it — do not leave it looking like a pipeline |
| Production | **Does not exist** (confirmed 2026-08-19, still true 2026-09-03). Building it is T18 |

## Requirements on the host

- PHP **8.3** with `mbstring`, `intl`, `pdo_mysql`, **`mongodb`**, and — for the concurrency
  suite — `pcntl` + `posix`. Without those last two, `BookingConcurrencyTest` **skips silently**
  and a build goes green having never run the race it exists for.
- MySQL **8** and MongoDB **7**. Both, always: `phpunit.xml` sets `DB_CONNECTION=sqlite`, but that
  only changes Laravel's *default* connection — every model pins `mysql` or `mongodb` explicitly.
- Node for the asset build (`npm ci && npm run build`).
- Three DNS names, one per panel: `ADMIN_DOMAIN`, `ORG_DOMAIN`, `USER_DOMAIN`. The panels are
  separated by **host**, not by path prefix, so one hostname is not enough.

## Environment file

Copy `.env.example` and read the annotations — two blocks are easy to get wrong:

- **`DB_*` is MySQL. `DB_*_ALT` is MongoDB** (`config/database.php`). The example file used to
  declare `DB_PORT_ALT=3306`, which pointed Mongo at the MySQL port; a fresh clone following it
  could not start. Both blocks are annotated now and CI's `env-diff` gates them.
- `QUEUE_CONNECTION=database` and `SESSION_DRIVER=database` are **deliberate** for the MVP. Redis
  is full-product scope — do not "fix" this.

## ⚠️ The staging script as it stands (reviewed 2026-09-03) — three problems

The build step currently running on staging has one destructive line and two omissions.

### 1. `php artisan db:seed --force` DELETES DATA ON EVERY DEPLOY — remove it

`DatabaseSeeder` calls `AdminSeeder`, which does `table("admin")->truncate()`, and
`OrganizationSeeder`, which does `table("organization")->truncate()` and then re-inserts **only**
org 1 (Anoath) and org 2 (Day Day Help) with the password `abcd1234`.

So every deploy to staging:

- **deletes every organization created through the UI** — anything beyond ids 1 and 2;
- **resets the passwords** of orgs 1 and 2 back to the seed value;
- wipes and re-seeds `admin`;
- leaves MongoDB **untouched**, so `user_org_belonging`, `chatroom`, `flow_run`, `flow_version`,
  `message_template` and every `*_detail` document goes on pointing at organization ids that no
  longer exist. This is where orphaned documents come from.

Seeding is a **first-boot** action, not a deploy action. Run it by hand once when a box is built.

### 2. `config:clear` is missing before `config:cache`

A box that ever ran a broken build keeps serving the cache that 404'd all three panels. Clearing
first is cheap; not clearing is a panel-wide outage that presents as a routing bug.

### 3. Nothing here starts the queue or the scheduler

`queue:restart` does **not** start a worker — it signals existing workers to exit after their
current job so they reboot onto the new code. If nothing supervises `queue:work`, that line is a
no-op. And there is no crontab entry, so `schedule:run` never fires, so `flow:timer-sweep` never
runs: no chases, no give-ups, no appointment reminders, no reschedule chases, no slot-hold releases.
See *Long-running processes* below — this is the failure that cost a morning on 2026-09-03.

### Bonus, found while reviewing it: uploaded files were 404ing on the server

**Do not add `storage:link`** — this project serves uploads through Laravel, never through a
symlink (owner decision, 03/09/2026). It already did, via `filesystems.php`'s `serve => true`… but
the framework registers that URL as a **closure**, and
`FilesystemServiceProvider::serveFiles()` begins `if ($this->app->routesAreCached()) return;`.

The deploy runs `route:cache`. So on staging that route was never registered and **every chat
attachment, evidence file and receipt 404'd** — while working perfectly in local development, where
routes are not cached. Fixed 03/09/2026: `serve => false`, and
`App\Http\Controllers\StorageController` owns `/storage/{path}` as an **invokable**, which is the
repo's standing route rule for exactly this reason. `StorageServingTest` guards it, including an
assertion that the route is not a closure.

### Corrected build step

```bash
set -xe

cd /var/www/html/plantoo_tech_staging
git reset --hard
git pull

composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
/home/arfu/.nvm/versions/node/v25.3.0/bin/npm ci
/home/arfu/.nvm/versions/node/v25.3.0/bin/npm run build

# db — migrations only. NEVER db:seed here: the seeders truncate admin and organization.
php artisan migrate --force

# cache — clear BEFORE cache
php artisan config:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache

# queue — LAST. Restarts existing workers onto the new code; does not start one.
php artisan queue:restart
```

## Deploy sequence

Order matters, and every line here exists because leaving it out broke something once.

```bash
git pull                                   # or however the deploy tool delivers the code
composer install --no-dev --optimize-autoloader --no-interaction
npm ci && npm run build

php artisan migrate --force                # migrations are production-facing; see CLAUDE.md

php artisan config:clear                   # BEFORE config:cache, always — see note below
php artisan config:cache
php artisan route:cache
php artisan view:cache

php artisan queue:restart                  # MUST be the last line — see note below
```

**`config:clear` before `config:cache`.** A box that ever ran the broken build keeps serving the
cache that 404'd all three panels. Clearing is cheap; not clearing is a panel-wide outage that
looks like a routing bug.

**`queue:restart` last.** A long-lived `queue:work` keeps executing the code it booted with, so a
deployed job-class change never takes effect — it presents as "the fix didn't work". It is the
commit point of the deploy: with `set -xe`, an earlier failure should leave the old workers running
on the old code rather than half-restarted onto a broken deploy.

**Routes must be cacheable.** Every route is an invokable or a controller action, never a closure,
because `route:cache` fails on a closure. Keep it that way.

## Long-running processes

Three, and the product is visibly broken without the first two:

```bash
php artisan queue:work --tries=3           # supervisor: autostart, autorestart, 1+ process
php artisan schedule:work                  # or the standard crontab entry, below
node worker/index.js                       # supervisor: autostart, autorestart, stopwaitsecs=30
```

```cron
* * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1
```

The scheduler runs `flow:timer-sweep` every minute (`routes/console.php`). **Everything
time-based depends on it** — reminder chases, give-ups, appointment reminders, reschedule chases
and slot-hold releases are all `Timer` rows that something must come back and fire, and each firing
schedules the next one. With no runner a reminder loop stops dead at its first link, which reads as
a broken flow rather than a missing worker. It cost a morning on 2026-09-03.

`failed_jobs` is the triage table; see the README runbook.

### The Node worker (FP-T26)

A second runtime, and the only one. It does the two things PHP should not: fill an official PDF, and
(when D2 lands) run a workbook's formulas. **One process, two job types** — the cost of supervising,
deploying and watching a process is the same whether it does one job or two, which is why the second
arrives as a handler rather than as a second service.

- **It needs `npm ci --omit=dev` on the host.** The build already runs npm for the frontend; this
  adds a runtime dependency on `node_modules` remaining present after the build rather than being
  discarded. Three runtime packages: `pdf-lib`, `mysql2` and `@univerjs/preset-sheets-node-core`.
- **A browser for LAYOUT, never for arithmetic**, and the two are separate decisions that happen to
  land in the same process. The spreadsheet ran in headless Chromium at one point; it worked, and it
  was replaced — the board's own words are *"a browser cannot be trusted with a statutory figure"*,
  and a browser adds a rendering pipeline, a font stack and a window's worth of state to something
  that is arithmetic. The sums are Univer's own engine, headless, in Node.
  **Rendering a written document is the opposite case**: a browser is the best text layout engine
  anybody has, and the figures reach it already decided. So `playwright` is a runtime dependency
  again, and the host needs its browser: **`npx playwright install chromium`** after `npm ci`.
- **`playwright` is pinned to the same version as `@playwright/test`.** Two Playwright versions in
  one project fight over browser builds — installing one prunes the other's, and whichever ran last
  wins. Keep them equal when either moves.
- **`stopwaitsecs=30`.** The loop finishes the job in hand before exiting, so a deploy that kills it
  mid-render leaves a row `Running` and a half-written file.
- **Nothing reclaims a stuck `Running` row automatically**, deliberately: a document rendered twice
  is worse than one rendered late, and an official form filed twice is worse again. A row sitting in
  `Running` is how a crashed worker is found — check for one after any hard restart.
- **Unlike the two above, nothing is broken while it is down.** Rendering is not on any customer's
  path today; jobs queue up in `render_job` and drain when it comes back. Worth knowing during a
  deploy, and worth an alert before anything depends on it.
- Logs are one JSON line per job to stdout — `claimed`, `done` with the hash, `failed` with the
  reason. Point the supervisor's `stdout_logfile` somewhere and that is the whole observability
  story for now. `worker/README.md` carries the supervisor stanza.
- `node worker/index.js --once` drains whatever is queued and exits, for a host that would rather
  run it from cron than supervise a process.

## Seeding — read before running it anywhere with real data

`AdminSeeder` and `OrganizationSeeder` **truncate**. Running `db:seed` on a live environment
DELETES both organizations and leaves `user_org_belonging` documents pointing at ids that no
longer exist. Safe on a fresh box, destructive the moment anyone has created anything.

A full reset is two commands **in this order**:

```bash
php artisan db:wipe --database=mongodb --force   # FIRST
php artisan migrate:fresh --seed --force
```

`migrate:fresh` only drops MySQL. Without the Mongo wipe first, every document survives what looks
like a complete reset — and the result is a MySQL schema with no rows pointing at a Mongo store
still full of the old ones.

A clean seed produces exactly **one admin and two organizations**, and nothing else: no demo flow,
no demo service, no demo user. That is deliberate — building those through the UI *is* the test
(`docs/mvp-test-plan.html`).

## Post-deploy smoke check

Five minutes, in this order. Anything failing here is a broken deploy, not a flaky test.

1. `curl -fsS https://«org host»/api/health` → `{"status":"ok"}`.
2. Load each of the three panel hosts signed out. Each shows its login form; **none returns 500**
   (an unauthenticated navigation returning 500 is a regression the browser suite has caught before).
3. Sign into the org panel. The dashboard renders — a blank page means the bundle died on an
   un-imported constant, which has happened and which no PHPUnit test can see.
4. Confirm both processes are up: `php artisan queue:work` and the scheduler. Then check
   `select count(*) from timer where status = 1 and fire_at <= now()` is not growing — a rising
   count is the signature of a dead sweep.
5. Run one flow end to end as a customer in a throwaway organization, and check the message
   arrives in the org Inbox.

Never smoke-test in a real organization. Build a dedicated testing org.

## Demo walkthrough

Six scenarios, in order, against a freshly built environment. Preserved here from the MVP release
card because the content is release-time work, not a task. **It needs the content built through the
UI first**: a clean seed creates two EMPTY organizations — no demo flow, no service, no user.

1. **Tenancy.** Two organizations; suspend one; its chat entry shows unavailable; reactivate.
2. **1-way.** An announcement flow; the message appears in the customer panel.
3. **Booking.** Complete the flow in org A; the appointment shows in *My Schedule* and in the org
   panel; the slot is gone from availability.
4. **2-way.** Go quiet on a choice node; the timer fires; the reminder arrives; acknowledge.
   (Needs the scheduler running — see *Long-running processes*.)
5. **3-way.** Upload evidence; approve it in the org's Verifications; the receipt appears; confirmed.
6. **Isolation and concurrency.** A cross-org URL is not-found; parallel booking gives 1×201 and
   19×409.

## Not automated, on purpose

`npm run e2e` (Playwright, 13 specs) is not in the deploy path. It needs a running app with real
hosts, and wiring it is T12's open checkbox — see `docs/progress.md`.
