# PRD — stock_system

Evidence date: 2026-10-02. Source: `git@bitbucket.org:overdrive/stock_system.git`, `main` at `02b62e87`.

Provenance labels: **Documented**, **Implemented**, **Inferred**, **Planned**, **Unknown**.

No formal PRD existed in the repository or in `silicon_ark/stock_system/` before this file. Requirements below are recovered from code, migrations, and `docs/`. They are not a new product decision.

Secrets in the tracked `.env`, `public/deploy.php`, and `app/Controllers/Slack.php` are not reproduced.

## Product purpose

**Documented** in `docs/tasks/TASK-015.md` and `docs/db_docs/shop_products.md`: a public Annie's Angels costume-rental catalogue with a session cart and an email enquiry. The office system (SQL Server Express) is the documented source of catalogue rows. This application stores a mirror and does not track stock quantity.

**Documented** in `README.md` and `docs/index.md`: the same codebase is "Admin Overdrive", a CodeIgniter 4 admin platform with documents, mailbox, bulk email, and a WhatsApp support desk.

**Implemented:** both surfaces are routed in `app/Config/Routes.php`. `/` is the storefront. `/login` is staff login. `/home` is the staff dashboard.

**Unknown / Requires Confirmation:** which of those modules are in production use for Annie's Angels, and which are inherited platform code that this deployment does not operate.

## Users and roles

| Actor | Evidence | Provenance |
|---|---|---|
| Public visitor | Routes `/`, `/search`, `/item/{slug}`, `/cart`, enquiry. Excluded from `isLoggedIn` in `app/Config/Filters.php` | **Implemented** |
| Staff user | Session keys `isLoggedIn`, `username`, `role`, `userID` set in `Welcome::index` | **Implemented** |
| Viewer | Role name `Viewer` in `UserRole` seeder. After login, `getPostLoginDefaultPath()` sends that role to `documents` | **Implemented** |
| System Admin, Admin, System User, Company Admin, Company User | Inserted by `app/Database/Seeds/UserRole.php` when the name is absent | **Implemented** as seeder data. Whether those rows exist in the `annies` database was not queried |
| Developer | Granted by Shop Sync, Scheduler, Mailbox, Support, and Customise seeders. `UserRole.php` checks for the name `Developer` and then inserts `System Admin` | **Implemented** seeder mismatch. **Unknown** whether a Developer row is created elsewhere |
| Menu grant | `Authorization` loads `user_menu` by the first URL segment and `checkUserAccess` for the session role. Missing menu redirects to `/`. Denied access redirects to `blocked` | **Implemented** |
| Blocked IP | `Welcome` constructor looks up the client IP in `BlockedUsersModel` | **Implemented**. The block threshold is not restated here; see `Welcome.php` |
| Sync Coordinator | `POST /api/shop-sync/log` with `shop.syncApiKey` | **Implemented** receiver. The caller is **Documented** as external |
| WAHA | `POST /support/webhook/waha` | **Implemented** receiver |
| Cron ping | `GET /scheduler/run` and `GET /cronJobs` are outside the login filter | **Implemented**. Key check is described in `docs/scheduler.md` as applying in production |

Hard-coded role ids `1`, `3`, and `5` appear in document UI rules (`docs/new/documents.md`). Viewer detection for upload uses the database id of the role named Viewer. Those two mechanisms can disagree if seed order changes. **Documented** conflict inside the repo's own DMS notes.

## Major workflows

### WF-1 — Browse the catalogue

Trigger: visitor opens `/` or `/search`.

Input: optional query `q` and facets `category`, `subcat`, `item`, `gender`, `era`. Page number.

Behaviour (`CatalogueQuery::search`):

- Only rows with `is_visible = 1`.
- Text match on `code`, `description`, or `barcode`.
- Page size from `shop.itemsPerPage`, default 40.
- Order by `description` ascending.
- Facet values come from the same visible set.

Output: catalogue view. Hire `price` is stored and is not rendered. Product detail says no price is displayed and that availability is confirmed by email (`app/Views/storefront/product.php`).

Error: an invisible or unknown slug does not enter the visible query. Exact not-found copy was not separately verified beyond the controller existing at `Storefront\Product::show`.

Provenance: **Implemented**.

### WF-2 — Cart and enquiry

Trigger: visitor posts `cart/add`, `cart/update`, or `cart/remove`, then `enquiry/submit`.

Cart (`CartService`):

- Add loads the product only when `is_visible = 1`. Otherwise add returns false.
- Quantity stacks on the same product id.
- Cart lives in session key `shop_cart`.
- Update with quantity `<= 0` removes the line.

Enquiry (`EnquiryService::submit`):

- Empty cart returns an error and does not write an order.
- Name required. Email must pass `FILTER_VALIDATE_EMAIL`. From date required and must not be before today. Days must be at least 1.
- On success, one `shop_orders` row and one `shop_order_items` row per cart line, inside a transaction. Line price is not stored.
- Reference comes from `ReferenceGenerator`.
- Email subject `New costume enquiry — Ref {reference}` goes to `shop.enquiryEmail` (fallback `email.fromEmail`) and to the customer, via `sendMailDirect`.
- If either send fails, `email_failed` is set to 1 and the order is still kept. Cart is cleared either way.
- Confirmation route: `order/confirmed/{segment}`.

Provenance: **Implemented**. Production recipient address is an open question in `TASK-015.md`.

### WF-3 — Staff login

Trigger: `GET /login` shows the form (`Welcome::index` when email is absent). `POST GetLogin` submits it.

Behaviour:

- Already logged in: Viewer goes to `documents`; everyone else goes to `home`.
- Inactive user (`active == 0`): flash "access has been revoked" and redirect to `/` (the storefront, not `/login`).
- Unknown user or bad password: login history status `1`, same wrong-credentials flash, redirect to `/`.
- Good password: if system setting `2FA` is off, session is created and last login is updated.
- If `2FA` is on and `check2faEmailConfirmationDate` says the code is still valid, session is created. Otherwise a code is emailed and the browser goes to `2fa-validation`.
- Passwords are checked with `password_verify`.

Separate TOTP endpoints exist on `Users` (`totpBeginSetup`, `totpConfirmSetup`, `totpDisable`) backed by `TotpService` and migration `2026-04-17-120000_AddUserTotpColumns.php`. **Implemented** as staff profile actions. How they interact with the email `2FA` setting is **Unknown / Requires Confirmation**.

Password reset: `forgot-password`, `sendResetPasswordLink`, `reset-password`, `resetPassword`. Routes are outside the login filter. **Implemented** as routes. Token lifetime was not re-derived for this PRD.

Provenance: login path **Implemented**. The redirect of failures to the public storefront is **Implemented** and is called out because the flash may not render on the storefront landing.

### WF-4 — Catalogue sync log

Trigger: coordinator `POST /api/shop-sync/log`.

Auth (`ShopSyncAuth`): key from query `key`, header `X-Shop-Sync-Key`, or `Authorization: Bearer`. `ShopSyncService::verifyApiKey` compares it to `shop.syncApiKey`. If that env value is empty, the check succeeds only when `ENVIRONMENT === 'development'`. Otherwise 401 JSON `Unauthorized sync request`.

Body (`recordRun`): `status` must be `success`, `partial`, or `failed`. Counts and timestamps are stored on `shop_sync_log`. Invalid status is rejected.

Staff with the Shop Sync menu open `/shop-sync` and `shop-sync/getLogs`. Seeder grants `Developer`, `System Admin`, and `Admin`.

This endpoint does not upsert products. Product upsert is **Documented** as the coordinator's job (`docs/shop_sync_contract.md`) and is **not implemented** in this PHP tree.

Provenance: log API **Implemented**. Upsert **Planned** / external.

### WF-5 — Documents

Trigger: signed-in user opens `/documents`. Filter `timeAccess` also applies.

**Documented** in `docs/new/documents.md` and **Implemented** in `Documents.php` / `DocumentModel`:

- Upload of PDF, JPG, and PNG, with CSRF on the upload posts.
- Viewer role does not get the upload button; upload API returns 403 for that role.
- Viewer list and search return only documents assigned in `user_documents` (row-level filter in the model).
- Other roles see active documents, filterable by type, category, and search.
- Share-to-viewer, download, edit, delete, and audit screens exist.
- Time window: if the user has a time restriction and the clock is outside it, `TimeAccessFilter` redirects to `blocked`.

OCR (Tesseract, Poppler) and optional RAG (Neuron AI, OpenAI embeddings) are **Documented** in `README.md`. They depend on binaries and API keys that are not in git as working services.

### WF-6 — Mailbox, bulk email, support, scheduler

These are staff modules. Each is routed, has models and migrations, and has a menu seeder.

| Module | Entry | External system | Provenance |
|---|---|---|---|
| Mailbox | `/mailbox`, `php spark mailbox:poll` | IMAP. Env notes in `docs/mailbox_env.md` | **Implemented** |
| Bulk email | `/bulk_email` | SMTP; optional AI template helper | **Implemented** |
| Support desk | `/support`, webhook `/support/webhook/waha` | WAHA | **Implemented**. Live send needs WAHA (**Documented** blocker in `docs/index.md`) |
| Scheduler | `/scheduler`, `GET /scheduler/run`, `php spark scheduler:run` | Optional HTTP webhooks and cron-job.org ping | **Implemented** |
| Chat | `/chat`, `/chatGPT` | OpenAI | **Implemented** code path. Key is env configuration |

Mailbox and documents both use `timeAccess`. Support channel create/delete is **Documented** as limited to Developer, System Admin, and Admin (`docs/support_env.md`).

## Integrations

| System | Direction | Trigger | Data | Auth | Failure |
|---|---|---|---|---|---|
| MySQL | App → DB | Every request that uses models | Shop, users, documents, mail, support | DB credentials in `.env` | CodeIgniter database errors. Not separately specified |
| SMTP | App → mail host | Enquiry, 2FA email, queue, bulk email, mailbox reply | HTML bodies | `email.SMTP*` | Enquiry still saves and sets `email_failed` |
| Sync Coordinator | Inbound HTTP | After a coordinator run | Status, counts, timestamps | `shop.syncApiKey` | 401 or invalid status |
| SQL Server Express / Access | Not called from this repo | Documented coordinator | Catalogue columns in `docs/shop_sync_contract.md` | Not in this repo | **Unknown** here |
| WAHA | Outbound client and inbound webhook | Staff reply; inbound WhatsApp | Messages, media | `WAHA_API_KEY`; webhook filter `supportWebhookAuth` | **Unknown** beyond service classes. Live use requires WAHA up |
| IMAP | Poll command | `mailbox:poll` or scheduler spark command | Mail into tickets | `docs/mailbox_env.md` | Covered by mailbox library tests at unit level |
| OpenAI | Outbound | Chat, RAG, bulk-email AI | Prompts and embeddings | `OPENAI_API_KEY` | RAG is optional per README |
| PnP EDI SOAP/REST | Classes `Soap` and `Rest` | No caller under `app/` | GLN, API key and secret arguments | Arguments passed in | Unused from this tree. **Unknown** if production calls them |
| Slack | `Slack::sendMessage`, `public/deploy.php` | Route `GET slack` is outside login; deploy script | A message string | Webhook URLs hard-coded | Deploy script dies on bad token or IP |
| Google Drive | URLs stored on products | Sync, documented | `image_url_*` | None in this app | **Unknown** if URLs are public |
| SonarQube | Scan config only | `sonar-project.properties` | Project key for Admin Overdrive v2 | Not an app runtime | Not a user workflow |

## Constraints

- Public catalogue must not treat this repository as a stock ledger. Quantity on an enquiry line is a requested count, not an availability check.
- Price may be stored. The storefront must not display it. That behaviour is in the views today.
- Web-only columns `is_visible`, `is_featured`, `featured_sort`, `web_copy`, and `web_notes` are documented as never overwritten by sync. There is no staff UI in this repo to edit them (admin deferred).
- CSRF is global except `support/webhook/*` and `api/shop-sync/log`.
- Login is global except the public storefront, auth, tracking pixels, cron endpoints, the sync log, the support webhook, Slack, and the resource routes that have no controllers.
- Empty `shop.syncApiKey` allows the sync log in the development environment.
- `public/info.php` exposes `phpinfo()` with no auth check in that file.
- Tracked `.env` is not ignored by `.gitignore`.

## Requirements discovered from the implementation

| ID | Statement | Provenance |
|---|---|---|
| FR-001 | A visitor can open the home page and a faceted catalogue of visible costumes without signing in | **Implemented** |
| FR-002 | Catalogue search matches code, description, and barcode, and filters category, sub-category, item, gender, and era | **Implemented** |
| FR-003 | Hire price is stored and is not shown on the item page or cart | **Implemented** and **Documented** in the views |
| FR-004 | A visitor can add only visible products to a session cart and change or remove quantities | **Implemented** |
| FR-005 | Submitting an enquiry requires name, valid email, a from-date that is not in the past, days ≥ 1, and a non-empty cart | **Implemented** |
| FR-006 | A successful enquiry writes `shop_orders` and `shop_order_items` and attempts email to staff and the customer | **Implemented** |
| FR-007 | Email failure does not roll back the enquiry. `email_failed` is set | **Implemented** |
| FR-008 | Staff sign in at `/login` with username and password. Inactive accounts are rejected | **Implemented** |
| FR-009 | When setting `2FA` is enabled, an email code is required unless the confirmation window is still valid | **Implemented** |
| FR-010 | After login, Viewer lands on documents. Other roles land on `home` | **Implemented** |
| FR-011 | A signed-in request to a first URL segment that is not a menu for that role is redirected to `/`. A menu the role cannot access redirects to `blocked` | **Implemented** |
| FR-012 | Viewer document lists are limited to assigned documents. Upload is refused for that role | **Documented** and **Implemented** |
| FR-013 | Users with a time restriction cannot open documents or mailbox outside the window | **Implemented** |
| FR-014 | An external client can append a shop sync log when the API key matches, or when the key is unset and the environment is development | **Implemented** |
| FR-015 | Staff with the Shop Sync menu can read sync logs and product counts | **Implemented** |
| FR-016 | Support staff can work WhatsApp tickets when WAHA is configured | **Implemented** code. Live dependency **Documented** |
| FR-017 | Mailbox polling and bulk-email sends can be run from Spark and from the scheduler whitelist | **Implemented** |
| FR-018 | New synced products stay hidden until staff publish them | **Documented** in the sync contract. **Not implemented** as a default (`is_visible` defaults to 1) and no publish UI was found |

Nothing else is marked **Planned** except the deferred storefront epics in `TASK-015.md` (coordinator upsert in-repo, staff product admin, image pipeline).

## Edge cases

| Case | Behaviour | Provenance |
|---|---|---|
| Empty cart on enquiry | Error, no order | **Implemented** |
| Past from-date | Validation error | **Implemented** |
| Invisible product add | `CartService::add` returns false | **Implemented** |
| Enquiry mail fails | Order kept, `email_failed = 1`, cart cleared | **Implemented** |
| Sync status not in the allowed set | `recordRun` returns an error | **Implemented** |
| Missing sync key in production | 401 | **Implemented** |
| Missing sync key in development | Request allowed | **Implemented** |
| Viewer outside access window | Redirect to `blocked` | **Implemented** |
| Failed login | Redirect to storefront `/` | **Implemented** |
| Routes whose controllers are missing | Would 404 or error if called. Controllers were not found | **Unknown** runtime message |
| Duplicate enquiry submit | No idempotency key was found. A second submit can create another order | **Inferred** from the absence of a dedupe check |
| Concurrent sync writers | Not specified in this repo | **Unknown** |
