# Plantoo_tech

A multi-tenant **chatflow + booking platform**. Organizations (tenants — a salon, a clinic, a
studio) author conversational flows; their customers run those flows in a chat surface and come
out the other side with a booking, an acknowledgement, or a verified document.

Last updated 2026-08-19.

---

## What it actually does

The product is a **flow engine** with booking attached, supporting three interaction patterns:

| Pattern | Shape | Example |
|---|---|---|
| **1-way** | push | "The salon is closed Monday." |
| **2-way** | push + acknowledgement, with a reminder loop | "Confirm tomorrow's appointment" — re-asks on a timer, gives up after N attempts |
| **3-way** | push + action + verification | "Upload your payment receipt" → customer submits evidence → staff approve or reject |

A flow is a JSON **node graph** (nine node types) stored as an immutable version. Publishing
moves a pointer at one version; editing writes a new one. The contract is
[`docs/flow-schema.md`](docs/flow-schema.md) — **never invent node types or fields not defined
there**.

### The engine is pure

`app/Flow/*` is a reducer: `(definition, state, event) → (newState, effects)`. No database, no
HTTP, no queue, no clock inside it. Tenancy scoping happens *before* definitions reach it;
effects are executed *outside* it by `FlowRunner`. This is why the engine is unit-testable
without a database, and it is worth preserving.

---

## Panels

Three separate React bundles, three hostnames, one Laravel app.

| Panel | Who | Host (local) | Auth guard |
|---|---|---|---|
| **Admin** | Platform staff (us) | `admin.localhost:8002` | `sanctum-admin` |
| **Org** | Tenant staff (the salon) | `org.localhost:8002` | `sanctum-org` |
| **User** | End customers | `app.localhost:8002` | `app_user` (session) |

**Registration is invitation-only.** The only path that creates a `User` is
`User\InvitationController::verifyAndRegister`, which requires a valid org invitation or
join-link token plus an emailed code, and attaches the user to that org. There is deliberately
no open signup, and no anonymous "contact" identity — the anonymous chat widget and the whole
`/api/v1/*` surface were removed on 2026-08-06. Every user therefore belongs to at least one
organization, which is what lets the chat entry point scope to the user's own orgs.

---

## Stack

- **Backend** — Laravel (latest LTS), PHP 8.3, Sanctum
- **Datastores** — MySQL 9.6 (28 tables) + MongoDB (20 collections)
- **Frontend** — React + Vite, **plain JavaScript, no TypeScript**, PrimeReact via the
  `@/prime-react` barrel
- **Queue** — **database driver, by design for the MVP.** Redis is full-product scope; this is a
  recorded decision, not an oversight
- **Tests** — class-based PHPUnit (Pest is not adopted) + Playwright

---

## Storage split

Both stores are first-class, but **a single operation should own one of them**.

### MySQL — relational state
Organizations, users, `flow` (definitions live in Mongo), bookings and appointments, services,
resources, payments, timers, notifications, admin, and the framework tables.

**Availability is not here.** `resource_availability_rule` was dropped on 2026-08-11; weekly
windows now live in the Mongo `resource_detail.availability` document.

### MongoDB — documents
- **Chat domain** — `chatroom` (the room) + `flow_run` (one execution) + `chatroom_message`
- **Flow definitions** — `flow_version`
- **Everything `*_detail` / `*_detail_config`** — user, organization, service, appointment,
  resource, user_org
- **Membership** — `user_org_belonging`
- **Admin** — `admin_role`, `admin_right`, `admin_setting`, `message_template`

Models extend `MongoDB\Laravel\Eloquent\Model` and set `protected $table` — the package reads
`$table`, not `$collection`. Collection names are **singular**.

### Crossing between them

Document IDs are 24-char ObjectId **strings**. MySQL rows that point at one
(`timer.flow_run_id`, `payment.chatroom_id`, `booking.chatroom_id`) store `varchar(24)`.
**Never cast one to int** — it yields a meaningless number and the lookup silently finds
nothing, which is exactly the bug that made the timer sweep a no-op for two weeks.

A transaction covers **one connection only**. When an operation genuinely needs both, commit the
SQL work first, then write the document store — never inside the transaction — so a failure
cannot leave a document pointing at a rolled-back row. Prefer a retryable queued job when the
second write matters. `AnnounceStatusChange` is the reference implementation: MySQL commits, then
a job appends to chat under a unique `dedupe_key` so replay is safe.

### The user ↔ org pivot is not a pivot table

It lives in the Mongo `user_org_belonging` collection, one document per user:

```jsonc
{ "user_id": 12, "organizations": [ { "id": 4, "joined_at": "…", "via": "…", "user_type_ids": [2] } ] }
```

Use `User::organizationIds()` / `belongsToOrg()` / `attachOrganization()` / `userTypeIds()` /
`hasUserType()` / `assignUserType()` / `unassignUserType()` rather than writing the collection
directly — those helpers use atomic `$push` / `$addToSet` / `$pull`, because rewriting the whole
array silently loses concurrent writes.

---

## Running it locally

**Prerequisites:** PHP 8.3 with the `mongodb` extension, Composer, Node 20+, MySQL 8+, MongoDB 7+.

```bash
composer install
npm install

cp .env.example .env          # then fill in both DB blocks — read the comments, the _ALT
php artisan key:generate      # vars are MONGODB, not a second MySQL

php artisan migrate:fresh --seed
npm run dev                   # Vite dev server
php artisan serve --port=8002
```

Point the three hostnames at localhost in `/etc/hosts`:

```
127.0.0.1  admin.localhost org.localhost app.localhost
```

Two long-running processes are **required** for anything time-based to work — see *Runbook*.

### Seeded logins

`DemoFlowSeeder` builds a complete working tenant: Demo Salon, one resource, a 30-minute
haircut, weekly availability, and three published flows (booking, 2-way ack, 3-way evidence).

| Panel | Email | Password |
|---|---|---|
| User | `demo_user@plantoo.test` | `abcd1234` |
| Org | `demo_salon@plantoo.test` | `abcd1234` |

The seeder is idempotent — re-run it freely with
`php artisan db:seed --class=DemoFlowSeeder`.

---

## Commands

| Command | What it does |
|---|---|
| `composer test` | 295 PHPUnit tests |
| `npm run e2e` | 13 Playwright specs across 6 files |
| `npm run dev` / `npm run build` | Vite |
| `php artisan migrate:fresh --seed` | Rebuild both stores and seed |
| `php artisan queue:work` | **Required** — reminders and status announcements are queued jobs |
| `php artisan schedule:run` | **Required** (every minute, via cron) — drives the timer sweep |
| `php artisan flow:timer-sweep` | Fires overdue timers the queue lost. Runs every minute via the scheduler |
| `php artisan plantoo:sweep-orphans` | Deletes rows/documents whose owning parent is gone |

---

## Runbook

### Two processes the deploy has no effect without

```
supervisor:  php artisan queue:work
cron:        * * * * * php artisan schedule:run
```

Without the worker, reminders and booking-status announcements never fire. Without cron, the
timer sweep never runs — and the sweep is the safety net for everything the queue drops.

### How timers work, and why there are two paths

Every reminder is **both** a delayed queue job *and* a durable `timer` row. The job is the
normal path; `flow:timer-sweep` catches what the queue lost (worker crash, a delay that never
came back, a job deleted by hand). Both go through `App\Services\TimerClaim`, which flips the row
to `Fired` under `SELECT … FOR UPDATE` — so the two racing for the same timer is safe, and
exactly one wins.

`timer.kind` decides what firing means: `1 FlowNode` re-enters the engine at `node_id`,
`2 AppointmentReminder` announces an upcoming appointment in its chatroom.

The scheduler entry is `withoutOverlapping()` but deliberately **not** `onOneServer()` — that
needs a shared cache lock and the MVP cache store is per-process. Two app servers will both
sweep; `TimerClaim` is what keeps that correct.

### failed_jobs triage

```bash
php artisan queue:failed          # list
php artisan queue:retry {id}      # retry one
php artisan queue:retry all
```

Jobs that matter and can land here: `AnnounceStatusChange` (booking status → chat),
`FireAppointmentReminder`, `FireConversationTimer`. All three are **idempotent on replay** —
`AnnounceStatusChange` via the unique `dedupe_key` index, the timer jobs via `TimerClaim` — so
retrying is safe and is the correct first response.

### Health

`GET /api/health` returns 200. `php artisan about` shows which queue, cache and DB connections
are actually live — worth checking after any deploy, since the queue driver being wrong is
silent.

---

## API surface

213 routes. Verb-suffix actions, not REST resources — `/user/info/get`, `/org/flow/publish`.
Routes are **invokables, never closures** (closures break `route:cache`).

| Prefix | Panel | Covers |
|---|---|---|
| `/api/admin/*` | Platform admin | Organizations, admin roles/rights, platform health |
| `/api/org/*` | Tenant staff | Flows + publish, services, resources, availability, appointments, inbox, message templates, 3-way verification |
| `/conversation/*` | End user | `flows`, `start`, `submit`, `get`, `list` — the register-gated chat entry |
| `/booking/*`, `/availability/*` | End user | Slot listing and booking creation, org-scoped |
| `/detail/*`, `/*_detail_config/*` | All three | The gated detail/config system |

Responses use a `{ data, message, status }` envelope via `Controller::ok()` / `fail()`.

### Two rules that are load-bearing

- **All booking writes go through `BookingService`.** It owns the resource-row lock and
  idempotency. Never insert into `booking` directly.
- **Cross-tenant access is a test failure.** `tests/Feature/TenancyIsolationTest.php` must stay
  green at all times.

---

## Documentation map

| File | What it is | Tracked? |
|---|---|---|
| `README.md` | This file | ✅ |
| `docs/flow-schema.md` | **The flow contract.** Source of truth for node types and validation | ⚠️ gitignored |
| `docs/progress.md` | Task-by-task build record, decisions, known issues | ⚠️ gitignored |
| `docs/db-schema.html` | Per-column schema review with sign-off state (T19) | ⚠️ gitignored |
| `docs/coding-style.md` | Full style reference | ⚠️ gitignored |
| `CLAUDE.md` | Architecture + behavioural rules for working in this repo | ⚠️ gitignored |
| `TESTING.md` | Test inventory, how to run, manual checklist | ✅ |

> **`/docs` and `CLAUDE.md` are gitignored** (`.gitignore:29-30`), so none of that leaves this
> machine. That is fine for private notes, but `docs/flow-schema.md` is described in-code as the
> flow contract and `docs/progress.md` is the handover record — decide before handover whether
> those should ship with the repo.

---

## Conventions worth knowing before your first commit

- **`camelCase` identifiers.** `snake_case` only at the data boundary (DB columns, array keys,
  JSON payloads). Tables are **singular**, enforced with `protected $table`.
- **Every enum stored in a column is `: int`**, backed by `unsignedTinyInteger` — never MySQL's
  native `ENUM`. The display string lives in `meta()["code"]`. Mirror the ints for the frontend
  in `resources/js/shared/status.js`.
- **Eloquent, never raw SQL.** `DB::table(...)` and `whereRaw` are forbidden in app code;
  migrations may use `DB::statement` for DDL Schema cannot express.
- **Transactions are explicit** — `beginTransaction()` / `commit()` / `rollBack()` in a
  `catch (\Throwable $e)`, log, then **rethrow**. Scope to a named connection
  (`DB::connection("mysql")`), because `DB_CONNECTION` defaults to sqlite in stock Laravel.
- **Reversible migrations, always.** Never put a drop/rename in a deploy migration — split
  expand → migrate → contract. Never edit a migration that has been pushed.
- **Double quotes everywhere**, 4-space indent, K&R braces, `//` comments only — no docblocks.
- **Never use native `alert()` / `confirm()` / `prompt()`** in the frontend; use the `shared/`
  primitives.

Full reference: `docs/coding-style.md`. Architecture rules and the traps that cost time:
`CLAUDE.md`.
