← back to Quadrille Showroom
SAMPLE-REQUEST-SPEC.md
120 lines
# Sample-Request Submit — Scope (v1)
**Status:** SCOPED — not yet built. Some pieces are Steve-gated (marked 🔒).
**Origin:** the Sample Tray captures products + optional age but has **no submit** —
nothing is ever sent anywhere. Age (`visitorAge`, `showroom.js:57`) is localStorage-only.
This spec adds the real sample-request submission; age rides along with it.
**Context that shapes the design:**
- No database — everything is file-backed JSON (`showroom-products.json`, `thumb-cache/`).
- Local-only tool — localhost bypasses auth (`server.js:48-53`, DTD verdict C). Kiosk
capture, not an internet lead form — but v1 collects PII (see 🔒 Consent), so treat it
as PII-bearing regardless.
---
## 1. Endpoint — `POST /api/sample-request`
Express 5, `express.json()` already mounted (`server.js:45`). Add a body-size cap:
change to `express.json({ limit: '32kb' })`.
**Request body**
```jsonc
{
"samples": [ { "sku": "…", "pattern_name": "…", "color": "…" } ], // required, 1..50
"age": 42, // optional int 0..120 or null (mirrors client clamp 3915)
"contact": {
"name": "…", // optional string, ≤120 chars
"email": "…", // optional, RFC-lite regex, ≤160
"phone": "…" // optional, ≤40, digits/spaces/+()-
},
"notes": "…" // optional free text, ≤2000 chars
}
```
**Validation (reject 400 on fail)**
- `samples` present, array, length 1..50; each item has a string `sku`.
- `age` — `null` OR integer 0..120.
- `contact.email` — if present, passes a lightweight format check.
- Trim + length-cap every string field; strip control chars.
- Body > 32 kb → 413 (handled by the json limit).
**Response** — `{ ok:true, id, created_at }`
- `id` = `sr_` + short sha1(created_at + samples) slice.
- `created_at` = **server-stamped** ISO (never trust a client clock).
---
## 2. Storage — file **and** email
### 2a. Local JSONL (always, primary)
Append one line to `data/sample-requests.jsonl`. `mkdir -p`-safe, same pattern as
`THUMB_CACHE` (`server.js:162-163`). Record = the validated body + `id` + `created_at`
+ `ip` (already available as `req.ip`). Append-only; never rewrite the file.
### 2b. Email via George — picker BUILT, sending still gated
Two halves, both built:
- **Endpoint hook** (`server.js` `maybeQueueEmail`): on successful persist, if
`SAMPLE_REQUEST_EMAIL=on`, append a pending row to `data/sample-request-emails.jsonl`.
Default **off** → nothing is queued. Never fails the request (best-effort).
- **Picker** (`scripts/send-sample-request-emails.js`, `npm run send-sample-emails`):
turns queued rows into real mail via George (`POST {GEORGE_URL}/api/send`, Basic auth
from `GEORGE_BASIC_AUTH` or the `dw-agents` Keychain, optional `X-Send-Approval`
token), from the **steve-office** account. Marks each row `sent` + `sent_at` +
`message_id`; idempotent (only touches `sent:false` rows). Verified: George reachable
from Mac2, `--health`/`--dry-run`/gated-refusal all pass without sending.
- **🔒 STILL GATED — to actually send live you set:**
- `SAMPLE_REQUEST_EMAIL=on` (env on the server process) so requests get queued.
- `SAMPLE_REQUEST_EMAIL_TO=<recipient>` — the DW sales/info inbox. **Which inbox?**
Until set, the picker **refuses to send** (prints instructions, sends nothing).
- Run `npm run send-sample-emails` (add `--dry-run` to preview). NOT scheduled
autonomously — autonomous outbound send stays a deliberate step you turn on.
---
## 3. Front-end — tray submit flow
Add to `#sample-tray` (`showroom.html:299`, wired near `updateSampleTray` ~`showroom.js:4137`):
- Fields: **name, email, phone** (all optional), the existing **age** input, a **notes**
textarea.
- A **"Request Samples"** button — disabled when `sampleTray.length === 0`.
- On click: POST tray items + `_qh.visitorAge` + contact + notes.
- Success → clear tray + fields, clear `qh_visitor_age`, show inline confirmation.
- Failure → keep everything, show a retry message (never lose the visitor's input).
- **🔒 Consent line** (because PII is now collected): one line under the button, e.g.
*"We'll use your details only to send the samples you requested."* — confirm the exact
wording you want; this is the minimum notice for PII capture.
---
## 4. Admin read-back — `GET /api/sample-requests`
Behind auth; reads `sample-requests.jsonl` (newest first). Plus a small admin page
(`/admin/sample-requests` or a section in an existing admin surface) rendering each
request as a **card** with:
- The samples (SKU + pattern + color chip), age, contact, notes.
- **🕓 created date + time chip** per your standing admin-card rule —
`toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})`,
full ISO in the `title=`.
- Newest/Oldest sort reads the same `created_at`.
---
## 5. Build order (what ships when)
| Step | Piece | Gated? |
|------|-------|--------|
| 1 | `POST /api/sample-request` + JSONL persist + validation | ✅ built (38c4172) |
| 2 | Tray form + Request Samples button + confirmation UX | ✅ built (38c4172) |
| 3 | Admin `GET` + card list with date/time chip | ✅ built (38c4172) |
| 4 | Consent-line wording | 🔒 placeholder shipped; needs your exact copy |
| 5 | Email via George — endpoint hook + picker | ✅ built; sending 🔒 on recipient + your go |
Steps 1–3 shipped + verified (curl + Playwright, 0 console errors). Step 5's plumbing
(queue hook + `send-sample-request-emails.js` picker) is built and reachability-tested;
it will not send a single email until you set `SAMPLE_REQUEST_EMAIL=on` +
`SAMPLE_REQUEST_EMAIL_TO=<inbox>`. Step 4 (consent copy) + the recipient inbox are the
only remaining inputs from you.
**Cost:** $0 (all local). George email, once enabled, is $0 (Gmail API).