← 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).