# Package Submissions Admin Panel — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Capture `checkout.html` form submissions into a Node API (SQLite), geolocate by IP, send dual SendGrid emails, and surface everything in a React admin panel grouped by package.

**Architecture:** Two-service split under `admin/`. `admin/api` is an Express + better-sqlite3 service exposing a public submission endpoint and JWT-gated admin endpoints. `admin/web` is a Vite + React SPA. `checkout.html` POSTs real submissions to the API via a configurable absolute URL with CORS.

**Tech Stack:** Node 22, Express 4, better-sqlite3, jsonwebtoken, bcryptjs, multer, @sendgrid/mail, maxmind, express-rate-limit, dotenv. Tests: vitest + supertest (api), vitest + @testing-library/react (web). Web build: Vite + React 18 + react-router-dom.

## Global Constraints

- Source of truth for package data is `packages-html/checkout-packages.json`; never hardcode prices/tier names in the API — they arrive in the submission payload as a snapshot.
- Prices are **whole-dollar integers** (not cents). Store `due_today` as-is.
- AI pricing is two line items (Activation one-time + Maintenance/mo); `due_today` for AI billing = activation amount (matches checkout `dueToday()`).
- Brand facts (only if needed in default email templates), verbatim from CLAUDE.md §4: phone `(859) 523-3053`, email `hello@btwebgroup.com`, address `168 E. Reynolds Rd. Lexington, KY 40517`, "20+ years, serving Kentucky since 2005". Sender identity `Brian Evans - BT Web Group`.
- Emails must carry CAN-SPAM physical address; auto-responder conveys "modern AI/tech" positioning.
- First admin seeded as `admin@btwebgroup.com` / `admin123` with `must_change_pw=1`.
- Secrets (`SENDGRID_API_KEY`, `JWT_SECRET`, seed creds), `data/app.db`, `data/uploads/`, `*.mmdb`, `node_modules` are git-ignored. Never commit them.
- Email send failure must never fail a submission (lead is already saved).
- Geo lookup failure must never fail a submission (geo fields left null).
- CORS: only `ALLOWED_ORIGIN` may call `POST /api/submissions`.

---

## File Structure

```
admin/
  .gitignore
  README.md
  api/
    package.json
    vitest.config.js
    .env.example
    src/
      config.js          env loading + paths
      db.js              better-sqlite3 connection + migrate()
      migrations.js      ordered SQL statements
      seed.js            seed first admin + settings row
      server.js          express app factory (createApp) + listen
      middleware/
        auth.js          requireAuth (JWT verify)
        upload.js        multer CSV config
      services/
        geo.js           lookupIp(ip) -> geo object
        templates.js     defaults + render(template, tokens)
        mailer.js        sendSubmissionEmails(submission, settings)
      routes/
        auth.js          login, change-password, me
        submissions.js   create (public), list, detail, patch, file
        settings.js      get, put
    test/
      auth.test.js
      submissions.test.js
      settings.test.js
      geo.test.js
      templates.test.js
      helpers.js         build test app + in-memory db
    data/                (gitignored: app.db, uploads/, GeoLite2-City.mmdb)
  web/
    package.json
    vite.config.js
    index.html
    src/
      main.jsx
      App.jsx
      api.js
      auth.jsx           AuthProvider + useAuth + RequireAuth
      tokens.css
      group.js           groupByPackage(submissions) helper
      pages/
        Login.jsx
        Dashboard.jsx
        SubmissionDetail.jsx
        Settings.jsx
      components/
        GroupCard.jsx
        StatusBadge.jsx
    test/
      group.test.js
      api.test.js
```

`checkout.html` is modified in Task 14.

---

### Task 1: API scaffold + config + health endpoint

**Files:**
- Create: `admin/.gitignore`, `admin/api/package.json`, `admin/api/vitest.config.js`, `admin/api/.env.example`, `admin/api/src/config.js`, `admin/api/src/server.js`, `admin/api/test/helpers.js`, `admin/api/test/health.test.js`

**Interfaces:**
- Produces: `createApp(deps)` from `src/server.js` returning an Express app; `config` object from `src/config.js`.

- [ ] **Step 1: Create `admin/.gitignore`**

```
node_modules/
*.log
api/.env
api/data/*.db
api/data/uploads/
api/data/*.mmdb
web/dist/
```

- [ ] **Step 2: Create `admin/api/package.json`**

```json
{
  "name": "btw-admin-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch src/server.js",
    "test": "vitest run",
    "seed": "node src/seed.js"
  },
  "dependencies": {
    "@sendgrid/mail": "^8.1.3",
    "bcryptjs": "^2.4.3",
    "better-sqlite3": "^11.3.0",
    "cors": "^2.8.5",
    "dotenv": "^16.4.5",
    "express": "^4.19.2",
    "express-rate-limit": "^7.4.0",
    "jsonwebtoken": "^9.0.2",
    "maxmind": "^4.3.23",
    "multer": "^1.4.5-lts.1"
  },
  "devDependencies": {
    "supertest": "^7.0.0",
    "vitest": "^2.1.1"
  }
}
```

- [ ] **Step 3: Create `admin/api/vitest.config.js`**

```js
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { environment: "node", hookTimeout: 20000 } });
```

- [ ] **Step 4: Create `admin/api/.env.example`**

```
PORT=4000
JWT_SECRET=change-me-to-a-long-random-string
ALLOWED_ORIGIN=https://btwebgroup.com
TRUST_PROXY=false
SENDGRID_API_KEY=
GEOIP_DB=./data/GeoLite2-City.mmdb
DB_PATH=./data/app.db
UPLOAD_DIR=./data/uploads
SEED_ADMIN_EMAIL=admin@btwebgroup.com
SEED_ADMIN_PASSWORD=admin123
```

- [ ] **Step 5: Create `admin/api/src/config.js`**

```js
import dotenv from "dotenv";
import path from "node:path";
import { fileURLToPath } from "node:url";

dotenv.config();
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const abs = (p) => (path.isAbsolute(p) ? p : path.join(root, p));

export const config = {
  port: Number(process.env.PORT || 4000),
  jwtSecret: process.env.JWT_SECRET || "dev-insecure-secret",
  allowedOrigin: process.env.ALLOWED_ORIGIN || "http://localhost:5173",
  trustProxy: process.env.TRUST_PROXY === "true",
  sendgridKey: process.env.SENDGRID_API_KEY || "",
  geoipDb: abs(process.env.GEOIP_DB || "./data/GeoLite2-City.mmdb"),
  dbPath: abs(process.env.DB_PATH || "./data/app.db"),
  uploadDir: abs(process.env.UPLOAD_DIR || "./data/uploads"),
  seedAdminEmail: process.env.SEED_ADMIN_EMAIL || "admin@btwebgroup.com",
  seedAdminPassword: process.env.SEED_ADMIN_PASSWORD || "admin123",
};
```

- [ ] **Step 6: Create `admin/api/src/server.js`** (app factory; routes mounted in later tasks)

```js
import express from "express";
import cors from "cors";
import { config } from "./config.js";

// deps is an injection point so tests can pass a test db + stub mailer.
export function createApp(deps = {}) {
  const app = express();
  if (config.trustProxy) app.set("trust proxy", true);
  app.use(express.json({ limit: "1mb" }));
  app.locals.deps = deps;

  app.get("/api/health", (_req, res) => res.json({ ok: true }));

  // Later tasks mount: /api/auth, /api/submissions, /api/settings here.
  if (deps.mountRoutes) deps.mountRoutes(app);

  app.use((err, _req, res, _next) => {
    if (err.status) return res.status(err.status).json({ error: err.message });
    console.error(err);
    res.status(500).json({ error: "Internal error" });
  });
  return app;
}

// Bootstrap only when run directly (not under test import).
if (process.argv[1] && process.argv[1].endsWith("server.js")) {
  const { wireApp } = await import("./bootstrap.js").catch(() => ({ wireApp: null }));
  const app = wireApp ? wireApp(createApp) : createApp();
  app.listen(config.port, () => console.log(`API on :${config.port}`));
}
```

> Note: `bootstrap.js` is created in Task 9 (wires real db + routes). Until then, running `npm start` falls back to a bare app — acceptable mid-build.

- [ ] **Step 7: Create `admin/api/test/helpers.js`**

```js
import { createApp } from "../src/server.js";
export function makeApp(deps = {}) {
  return createApp(deps);
}
```

- [ ] **Step 8: Write failing test `admin/api/test/health.test.js`**

```js
import { describe, it, expect } from "vitest";
import request from "supertest";
import { makeApp } from "./helpers.js";

describe("health", () => {
  it("returns ok", async () => {
    const res = await request(makeApp()).get("/api/health");
    expect(res.status).toBe(200);
    expect(res.body).toEqual({ ok: true });
  });
});
```

- [ ] **Step 9: Install + run test**

Run: `cd admin/api && npm install && npm test`
Expected: PASS (health test green).

- [ ] **Step 10: Commit**

```bash
git add admin/.gitignore admin/api
git commit -m "feat(admin-api): scaffold express app + health endpoint"
```

---

### Task 2: Database connection + migrations

**Files:**
- Create: `admin/api/src/migrations.js`, `admin/api/src/db.js`, `admin/api/test/db.test.js`

**Interfaces:**
- Produces: `openDb(path)` -> better-sqlite3 instance with `migrate()` already run; `MIGRATIONS` array. Tables per spec §4: `users`, `submissions`, `settings`.

- [ ] **Step 1: Create `admin/api/src/migrations.js`**

```js
export const MIGRATIONS = [
  `CREATE TABLE IF NOT EXISTS users (
     id INTEGER PRIMARY KEY AUTOINCREMENT,
     email TEXT NOT NULL UNIQUE,
     password_hash TEXT NOT NULL,
     must_change_pw INTEGER NOT NULL DEFAULT 1,
     created_at TEXT NOT NULL DEFAULT (datetime('now'))
   )`,
  `CREATE TABLE IF NOT EXISTS submissions (
     id INTEGER PRIMARY KEY AUTOINCREMENT,
     group_key TEXT NOT NULL,
     group_label TEXT NOT NULL,
     tier_key TEXT NOT NULL,
     tier_name TEXT NOT NULL,
     billing TEXT NOT NULL,
     due_today INTEGER,
     price_snapshot TEXT,
     name TEXT, business TEXT, email TEXT, phone TEXT,
     action TEXT, assessment INTEGER,
     answers TEXT,
     csv_path TEXT, csv_original_name TEXT,
     ip TEXT, geo_city TEXT, geo_region TEXT, geo_country TEXT, geo_lat REAL, geo_lon REAL,
     source_url TEXT, utm TEXT,
     status TEXT NOT NULL DEFAULT 'new',
     created_at TEXT NOT NULL DEFAULT (datetime('now'))
   )`,
  `CREATE INDEX IF NOT EXISTS idx_submissions_group ON submissions(group_key)`,
  `CREATE INDEX IF NOT EXISTS idx_submissions_created ON submissions(created_at)`,
  `CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status)`,
  `CREATE TABLE IF NOT EXISTS settings (
     id INTEGER PRIMARY KEY CHECK (id = 1),
     admin_notify_email TEXT, from_email TEXT, from_name TEXT,
     autoresponder_subject TEXT, autoresponder_body TEXT,
     notify_subject TEXT, notify_body TEXT,
     updated_at TEXT NOT NULL DEFAULT (datetime('now'))
   )`,
];
```

- [ ] **Step 2: Create `admin/api/src/db.js`**

```js
import Database from "better-sqlite3";
import fs from "node:fs";
import path from "node:path";
import { MIGRATIONS } from "./migrations.js";

export function openDb(dbPath = ":memory:") {
  if (dbPath !== ":memory:") fs.mkdirSync(path.dirname(dbPath), { recursive: true });
  const db = new Database(dbPath);
  db.pragma("journal_mode = WAL");
  db.pragma("foreign_keys = ON");
  migrate(db);
  return db;
}

export function migrate(db) {
  const tx = db.transaction(() => {
    for (const stmt of MIGRATIONS) db.prepare(stmt).run();
  });
  tx();
}
```

- [ ] **Step 3: Write failing test `admin/api/test/db.test.js`**

```js
import { describe, it, expect } from "vitest";
import { openDb } from "../src/db.js";

describe("db", () => {
  it("creates the three tables", () => {
    const db = openDb(":memory:");
    const names = db.prepare(
      "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
    ).all().map((r) => r.name);
    expect(names).toContain("users");
    expect(names).toContain("submissions");
    expect(names).toContain("settings");
  });
});
```

- [ ] **Step 4: Run test**

Run: `cd admin/api && npm test -- db`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add admin/api/src/migrations.js admin/api/src/db.js admin/api/test/db.test.js
git commit -m "feat(admin-api): sqlite connection + schema migrations"
```

---

### Task 3: Auth — seed, login, JWT middleware, change-password

**Files:**
- Create: `admin/api/src/seed.js`, `admin/api/src/middleware/auth.js`, `admin/api/src/routes/auth.js`, `admin/api/test/auth.test.js`

**Interfaces:**
- Consumes: `openDb` (Task 2), `config` (Task 1).
- Produces:
  - `seedAdmin(db, {email,password})` -> creates user if `users` empty.
  - `seedSettings(db)` -> inserts settings row id=1 with defaults if absent (uses `defaultSettings` from Task 7; for now insert nulls — Task 7 overwrites bodies).
  - `requireAuth(db)` -> express middleware setting `req.user = {id,email}`.
  - `authRouter(db)` mounting `POST /login`, `POST /change-password`, `GET /me`.
  - `signToken({id,email})` / token payload `{ sub, email }`.

- [ ] **Step 1: Create `admin/api/src/seed.js`**

```js
import bcrypt from "bcryptjs";
import { openDb } from "./db.js";
import { config } from "./config.js";

export function seedAdmin(db, { email, password }) {
  const count = db.prepare("SELECT COUNT(*) c FROM users").get().c;
  if (count > 0) return false;
  const hash = bcrypt.hashSync(password, 10);
  db.prepare(
    "INSERT INTO users (email, password_hash, must_change_pw) VALUES (?,?,1)"
  ).run(email, hash);
  return true;
}

export function seedSettings(db) {
  const row = db.prepare("SELECT id FROM settings WHERE id=1").get();
  if (row) return false;
  db.prepare(
    `INSERT INTO settings (id, admin_notify_email, from_email, from_name)
     VALUES (1, ?, ?, ?)`
  ).run("hello@btwebgroup.com", "hello@btwebgroup.com", "Brian Evans - BT Web Group");
  return true;
}

// Run directly: node src/seed.js
if (process.argv[1] && process.argv[1].endsWith("seed.js")) {
  const db = openDb(config.dbPath);
  const a = seedAdmin(db, { email: config.seedAdminEmail, password: config.seedAdminPassword });
  const s = seedSettings(db);
  console.log(`seed: admin ${a ? "created" : "exists"}, settings ${s ? "created" : "exists"}`);
}
```

- [ ] **Step 2: Create `admin/api/src/middleware/auth.js`**

```js
import jwt from "jsonwebtoken";
import { config } from "../config.js";

export function signToken(user) {
  return jwt.sign({ sub: user.id, email: user.email }, config.jwtSecret, { expiresIn: "12h" });
}

export function requireAuth() {
  return (req, res, next) => {
    const h = req.headers.authorization || "";
    const token = h.startsWith("Bearer ") ? h.slice(7) : null;
    if (!token) return res.status(401).json({ error: "Unauthorized" });
    try {
      const p = jwt.verify(token, config.jwtSecret);
      req.user = { id: p.sub, email: p.email };
      next();
    } catch {
      res.status(401).json({ error: "Unauthorized" });
    }
  };
}
```

- [ ] **Step 3: Create `admin/api/src/routes/auth.js`**

```js
import { Router } from "express";
import bcrypt from "bcryptjs";
import { signToken, requireAuth } from "../middleware/auth.js";

export function authRouter(db) {
  const r = Router();

  r.post("/login", (req, res) => {
    const { email, password } = req.body || {};
    const user = db.prepare("SELECT * FROM users WHERE email=?").get(String(email || "").toLowerCase());
    if (!user || !bcrypt.compareSync(String(password || ""), user.password_hash)) {
      return res.status(401).json({ error: "Invalid credentials" });
    }
    res.json({ token: signToken(user), mustChangePassword: !!user.must_change_pw });
  });

  r.get("/me", requireAuth(), (req, res) => res.json({ user: req.user }));

  r.post("/change-password", requireAuth(), (req, res) => {
    const { currentPassword, newPassword } = req.body || {};
    if (!newPassword || String(newPassword).length < 8) {
      return res.status(400).json({ error: "New password must be at least 8 characters" });
    }
    const user = db.prepare("SELECT * FROM users WHERE id=?").get(req.user.id);
    if (!bcrypt.compareSync(String(currentPassword || ""), user.password_hash)) {
      return res.status(400).json({ error: "Current password is incorrect" });
    }
    db.prepare("UPDATE users SET password_hash=?, must_change_pw=0 WHERE id=?")
      .run(bcrypt.hashSync(String(newPassword), 10), user.id);
    res.json({ ok: true });
  });

  return r;
}
```

> Login lookups lowercase the email; seed stores it lowercase already (`admin@btwebgroup.com` is lowercase).

- [ ] **Step 4: Write failing test `admin/api/test/auth.test.js`**

```js
import { describe, it, expect, beforeEach } from "vitest";
import request from "supertest";
import express from "express";
import { openDb } from "../src/db.js";
import { seedAdmin } from "../src/seed.js";
import { authRouter } from "../src/routes/auth.js";

function app(db) {
  const a = express();
  a.use(express.json());
  a.use("/api/auth", authRouter(db));
  return a;
}

describe("auth", () => {
  let db;
  beforeEach(() => {
    db = openDb(":memory:");
    seedAdmin(db, { email: "admin@btwebgroup.com", password: "admin123" });
  });

  it("rejects bad credentials", async () => {
    const res = await request(app(db)).post("/api/auth/login").send({ email: "admin@btwebgroup.com", password: "wrong" });
    expect(res.status).toBe(401);
  });

  it("logs in seeded admin and flags must-change", async () => {
    const res = await request(app(db)).post("/api/auth/login").send({ email: "admin@btwebgroup.com", password: "admin123" });
    expect(res.status).toBe(200);
    expect(res.body.token).toBeTruthy();
    expect(res.body.mustChangePassword).toBe(true);
  });

  it("changes password with valid token", async () => {
    const login = await request(app(db)).post("/api/auth/login").send({ email: "admin@btwebgroup.com", password: "admin123" });
    const res = await request(app(db))
      .post("/api/auth/change-password")
      .set("Authorization", `Bearer ${login.body.token}`)
      .send({ currentPassword: "admin123", newPassword: "newpass1234" });
    expect(res.status).toBe(200);
    const relog = await request(app(db)).post("/api/auth/login").send({ email: "admin@btwebgroup.com", password: "newpass1234" });
    expect(relog.body.mustChangePassword).toBe(false);
  });

  it("blocks /me without token", async () => {
    const res = await request(app(db)).get("/api/auth/me");
    expect(res.status).toBe(401);
  });
});
```

- [ ] **Step 5: Run test**

Run: `cd admin/api && npm test -- auth`
Expected: PASS (4 tests).

- [ ] **Step 6: Commit**

```bash
git add admin/api/src/seed.js admin/api/src/middleware/auth.js admin/api/src/routes/auth.js admin/api/test/auth.test.js
git commit -m "feat(admin-api): jwt auth, seeded admin, change-password"
```

---

### Task 4: Geo service (IP → location)

**Files:**
- Create: `admin/api/src/services/geo.js`, `admin/api/test/geo.test.js`

**Interfaces:**
- Produces: `createGeo(reader)` -> `{ lookup(ip) }` returning `{ city, region, country, lat, lon }` (nulls on miss); `openReader(dbPath)` async -> maxmind reader or `null` if file absent; `clientIp(req, trustProxy)` -> string.

- [ ] **Step 1: Create `admin/api/src/services/geo.js`**

```js
import fs from "node:fs";
import maxmind from "maxmind";

export async function openReader(dbPath) {
  if (!dbPath || !fs.existsSync(dbPath)) {
    console.warn(`[geo] GeoLite2 DB not found at ${dbPath}; geo lookups disabled`);
    return null;
  }
  return maxmind.open(dbPath);
}

export function createGeo(reader) {
  return {
    lookup(ip) {
      const empty = { city: null, region: null, country: null, lat: null, lon: null };
      if (!reader || !ip) return empty;
      try {
        const r = reader.get(ip);
        if (!r) return empty;
        return {
          city: r.city?.names?.en ?? null,
          region: r.subdivisions?.[0]?.names?.en ?? null,
          country: r.country?.names?.en ?? null,
          lat: r.location?.latitude ?? null,
          lon: r.location?.longitude ?? null,
        };
      } catch {
        return empty;
      }
    },
  };
}

export function clientIp(req, trustProxy) {
  if (trustProxy) {
    const xff = req.headers["x-forwarded-for"];
    if (xff) return String(xff).split(",")[0].trim();
  }
  return (req.ip || req.socket?.remoteAddress || "").replace(/^::ffff:/, "");
}
```

- [ ] **Step 2: Write failing test `admin/api/test/geo.test.js`** (stub reader — no real .mmdb needed)

```js
import { describe, it, expect } from "vitest";
import { createGeo } from "../src/services/geo.js";

describe("geo", () => {
  it("returns nulls when no reader", () => {
    const g = createGeo(null);
    expect(g.lookup("8.8.8.8")).toEqual({ city: null, region: null, country: null, lat: null, lon: null });
  });

  it("maps a maxmind record", () => {
    const reader = { get: () => ({
      city: { names: { en: "Lexington" } },
      subdivisions: [{ names: { en: "Kentucky" } }],
      country: { names: { en: "United States" } },
      location: { latitude: 38.04, longitude: -84.5 },
    }) };
    const g = createGeo(reader);
    expect(g.lookup("1.2.3.4")).toEqual({
      city: "Lexington", region: "Kentucky", country: "United States", lat: 38.04, lon: -84.5,
    });
  });

  it("survives a throwing reader", () => {
    const g = createGeo({ get: () => { throw new Error("boom"); } });
    expect(g.lookup("1.2.3.4").city).toBeNull();
  });
});
```

- [ ] **Step 3: Run test**

Run: `cd admin/api && npm test -- geo`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add admin/api/src/services/geo.js admin/api/test/geo.test.js
git commit -m "feat(admin-api): IP geolocation service (GeoLite2)"
```

---

### Task 5: Email templates + renderer + default bodies

**Files:**
- Create: `admin/api/src/services/templates.js`, `admin/api/test/templates.test.js`

**Interfaces:**
- Produces:
  - `render(tpl, tokens)` -> string with `{{token}}` replaced (unknown -> "").
  - `tokensFor(submission)` -> object with keys: `name, business, email, phone, package, tier, price, action, location, date, answers`.
  - `defaultSettings()` -> `{ admin_notify_email, from_email, from_name, autoresponder_subject, autoresponder_body, notify_subject, notify_body }`.

- [ ] **Step 1: Create `admin/api/src/services/templates.js`**

```js
export function render(tpl, tokens) {
  return String(tpl || "").replace(/\{\{(\w+)\}\}/g, (_, k) =>
    tokens[k] == null ? "" : String(tokens[k])
  );
}

function formatAnswers(answers) {
  if (!answers || typeof answers !== "object") return "";
  return Object.entries(answers)
    .map(([k, v]) => `- ${k}: ${Array.isArray(v) ? v.join(", ") : v}`)
    .join("\n");
}

export function tokensFor(s) {
  const loc = [s.geo_city, s.geo_region, s.geo_country].filter(Boolean).join(", ");
  const price = s.due_today != null ? `$${Number(s.due_today).toLocaleString("en-US")}` : "";
  const pkg = s.group_label ? `${s.group_label} · ${s.tier_name}` : s.tier_name;
  const answers = typeof s.answers === "string" ? safeParse(s.answers) : s.answers;
  return {
    name: s.name || "there",
    business: s.business || "",
    email: s.email || "",
    phone: s.phone || "",
    package: pkg || "",
    tier: s.tier_name || "",
    price,
    action: s.action || "",
    location: loc,
    date: s.created_at || "",
    answers: formatAnswers(answers),
  };
}

function safeParse(s) { try { return JSON.parse(s); } catch { return {}; } }

const ADDRESS = "168 E. Reynolds Rd. Lexington, KY 40517";
const PHONE = "(859) 523-3053";

export function defaultSettings() {
  return {
    admin_notify_email: "hello@btwebgroup.com",
    from_email: "hello@btwebgroup.com",
    from_name: "Brian Evans - BT Web Group",
    autoresponder_subject: "We got your request — BT Web Group",
    autoresponder_body:
`Hi {{name}},

Thanks for choosing {{package}} with BT Web Group. We received your details and a specialist will follow up within 1 business day.

What you requested: {{package}} ({{price}} due today)
{{answers}}

We pair 20+ years serving Kentucky with modern AI platforms and the latest technology to get you results faster. Questions? Call ${PHONE} or reply to this email.

— Brian Evans, BT Web Group
${ADDRESS}`,
    notify_subject: "New submission: {{package}} — {{name}}",
    notify_body:
`New checkout submission.

Name: {{name}} ({{business}})
Email: {{email}}
Phone: {{phone}}
Package: {{package}}
Due today: {{price}}
Action: {{action}}
Location: {{location}}
Submitted: {{date}}

Answers:
{{answers}}`,
  };
}
```

- [ ] **Step 2: Write failing test `admin/api/test/templates.test.js`**

```js
import { describe, it, expect } from "vitest";
import { render, tokensFor, defaultSettings } from "../src/services/templates.js";

describe("templates", () => {
  it("renders tokens and blanks unknowns", () => {
    expect(render("Hi {{name}} {{nope}}", { name: "Sam" })).toBe("Hi Sam ");
  });

  it("builds tokens from a submission row", () => {
    const t = tokensFor({
      name: "Sam", business: "Acme", group_label: "SEO", tier_name: "Growth",
      due_today: 997, action: "callback", geo_city: "Lexington", geo_country: "United States",
      answers: JSON.stringify({ website_url: "x.com", platforms: ["A", "B"] }),
      created_at: "2026-06-27",
    });
    expect(t.package).toBe("SEO · Growth");
    expect(t.price).toBe("$997");
    expect(t.location).toBe("Lexington, United States");
    expect(t.answers).toContain("platforms: A, B");
  });

  it("default settings include CAN-SPAM address", () => {
    expect(defaultSettings().autoresponder_body).toContain("168 E. Reynolds Rd.");
  });
});
```

- [ ] **Step 3: Run test**

Run: `cd admin/api && npm test -- templates`
Expected: PASS.

- [ ] **Step 4: Update `seedSettings` to use defaults**

In `admin/api/src/seed.js`, replace the `seedSettings` body insert with full defaults:

```js
import { defaultSettings } from "./services/templates.js";
// ...
export function seedSettings(db) {
  const row = db.prepare("SELECT id FROM settings WHERE id=1").get();
  if (row) return false;
  const d = defaultSettings();
  db.prepare(
    `INSERT INTO settings
       (id, admin_notify_email, from_email, from_name,
        autoresponder_subject, autoresponder_body, notify_subject, notify_body)
     VALUES (1,?,?,?,?,?,?,?)`
  ).run(d.admin_notify_email, d.from_email, d.from_name,
        d.autoresponder_subject, d.autoresponder_body, d.notify_subject, d.notify_body);
  return true;
}
```

- [ ] **Step 5: Run full api tests**

Run: `cd admin/api && npm test`
Expected: all PASS (health, db, auth, geo, templates).

- [ ] **Step 6: Commit**

```bash
git add admin/api/src/services/templates.js admin/api/test/templates.test.js admin/api/src/seed.js
git commit -m "feat(admin-api): email templates, token renderer, default settings"
```

---

### Task 6: Mailer (SendGrid) wiring

**Files:**
- Create: `admin/api/src/services/mailer.js`, `admin/api/test/mailer.test.js`

**Interfaces:**
- Consumes: `render`, `tokensFor` (Task 5).
- Produces: `createMailer({ send, apiKey })` -> `{ sendSubmissionEmails(submission, settings) }`. `send` defaults to `@sendgrid/mail`'s send; tests inject a fake. Returns `{ autoresponder: bool, notify: bool }` (true = attempted+resolved). Never throws.

- [ ] **Step 1: Create `admin/api/src/services/mailer.js`**

```js
import sg from "@sendgrid/mail";
import { render, tokensFor } from "./templates.js";

// sender: async (msg) => void. Defaults to SendGrid.
export function createMailer({ apiKey, sender } = {}) {
  if (apiKey) sg.setApiKey(apiKey);
  const send = sender || ((msg) => sg.send(msg));

  return {
    async sendSubmissionEmails(submission, settings) {
      const t = tokensFor(submission);
      const from = { email: settings.from_email, name: settings.from_name };
      const result = { autoresponder: false, notify: false };

      if (submission.email && settings.from_email) {
        try {
          await send({
            to: submission.email, from,
            subject: render(settings.autoresponder_subject, t),
            text: render(settings.autoresponder_body, t),
          });
          result.autoresponder = true;
        } catch (e) { console.error("[mailer] autoresponder failed:", e.message); }
      }

      if (settings.admin_notify_email && settings.from_email) {
        try {
          await send({
            to: settings.admin_notify_email, from,
            subject: render(settings.notify_subject, t),
            text: render(settings.notify_body, t),
          });
          result.notify = true;
        } catch (e) { console.error("[mailer] notify failed:", e.message); }
      }
      return result;
    },
  };
}
```

- [ ] **Step 2: Write failing test `admin/api/test/mailer.test.js`**

```js
import { describe, it, expect, vi } from "vitest";
import { createMailer } from "../src/services/mailer.js";
import { defaultSettings } from "../src/services/templates.js";

const sub = { name: "Sam", email: "sam@x.com", group_label: "SEO", tier_name: "Growth", due_today: 997, answers: "{}" };

describe("mailer", () => {
  it("sends both emails", async () => {
    const sender = vi.fn().mockResolvedValue();
    const m = createMailer({ sender });
    const r = await m.sendSubmissionEmails(sub, defaultSettings());
    expect(sender).toHaveBeenCalledTimes(2);
    expect(r).toEqual({ autoresponder: true, notify: true });
  });

  it("does not throw when sender fails", async () => {
    const sender = vi.fn().mockRejectedValue(new Error("sg down"));
    const m = createMailer({ sender });
    const r = await m.sendSubmissionEmails(sub, defaultSettings());
    expect(r).toEqual({ autoresponder: false, notify: false });
  });

  it("skips autoresponder without submitter email", async () => {
    const sender = vi.fn().mockResolvedValue();
    const m = createMailer({ sender });
    await m.sendSubmissionEmails({ ...sub, email: "" }, defaultSettings());
    expect(sender).toHaveBeenCalledTimes(1);
  });
});
```

- [ ] **Step 3: Run test**

Run: `cd admin/api && npm test -- mailer`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add admin/api/src/services/mailer.js admin/api/test/mailer.test.js
git commit -m "feat(admin-api): SendGrid mailer with safe failure"
```

---

### Task 7: Settings routes

**Files:**
- Create: `admin/api/src/routes/settings.js`, `admin/api/test/settings.test.js`

**Interfaces:**
- Consumes: `requireAuth` (Task 3), settings row (Task 5 seed).
- Produces: `settingsRouter(db)` mounting `GET /` and `PUT /`. Editable fields: `admin_notify_email, from_email, from_name, autoresponder_subject, autoresponder_body, notify_subject, notify_body`.

- [ ] **Step 1: Create `admin/api/src/routes/settings.js`**

```js
import { Router } from "express";
import { requireAuth } from "../middleware/auth.js";

const FIELDS = [
  "admin_notify_email", "from_email", "from_name",
  "autoresponder_subject", "autoresponder_body", "notify_subject", "notify_body",
];

export function settingsRouter(db) {
  const r = Router();
  r.use(requireAuth());

  r.get("/", (_req, res) => {
    const row = db.prepare("SELECT * FROM settings WHERE id=1").get();
    res.json({ settings: row || null });
  });

  r.put("/", (req, res) => {
    const body = req.body || {};
    const sets = FIELDS.filter((f) => f in body);
    if (!sets.length) return res.status(400).json({ error: "No fields to update" });
    const sql = `UPDATE settings SET ${sets.map((f) => `${f}=?`).join(", ")}, updated_at=datetime('now') WHERE id=1`;
    db.prepare(sql).run(...sets.map((f) => body[f]));
    res.json({ settings: db.prepare("SELECT * FROM settings WHERE id=1").get() });
  });

  return r;
}
```

- [ ] **Step 2: Write failing test `admin/api/test/settings.test.js`**

```js
import { describe, it, expect, beforeEach } from "vitest";
import request from "supertest";
import express from "express";
import { openDb } from "../src/db.js";
import { seedAdmin, seedSettings } from "../src/seed.js";
import { authRouter } from "../src/routes/auth.js";
import { settingsRouter } from "../src/routes/settings.js";

function build() {
  const db = openDb(":memory:");
  seedAdmin(db, { email: "admin@btwebgroup.com", password: "admin123" });
  seedSettings(db);
  const a = express(); a.use(express.json());
  a.use("/api/auth", authRouter(db));
  a.use("/api/settings", settingsRouter(db));
  return a;
}
async function token(a) {
  const r = await request(a).post("/api/auth/login").send({ email: "admin@btwebgroup.com", password: "admin123" });
  return r.body.token;
}

describe("settings", () => {
  let a;
  beforeEach(() => { a = build(); });

  it("requires auth", async () => {
    expect((await request(a).get("/api/settings")).status).toBe(401);
  });

  it("returns seeded defaults", async () => {
    const t = await token(a);
    const res = await request(a).get("/api/settings").set("Authorization", `Bearer ${t}`);
    expect(res.body.settings.from_name).toBe("Brian Evans - BT Web Group");
  });

  it("updates a field", async () => {
    const t = await token(a);
    const res = await request(a).put("/api/settings").set("Authorization", `Bearer ${t}`)
      .send({ admin_notify_email: "owner@btwebgroup.com" });
    expect(res.body.settings.admin_notify_email).toBe("owner@btwebgroup.com");
  });
});
```

- [ ] **Step 3: Run test**

Run: `cd admin/api && npm test -- settings`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add admin/api/src/routes/settings.js admin/api/test/settings.test.js
git commit -m "feat(admin-api): settings get/put routes"
```

---

### Task 8: Submissions routes (create public + admin list/detail/patch/file)

**Files:**
- Create: `admin/api/src/middleware/upload.js`, `admin/api/src/routes/submissions.js`, `admin/api/test/submissions.test.js`

**Interfaces:**
- Consumes: `requireAuth`, geo `{lookup}`, `clientIp`, mailer `{sendSubmissionEmails}`, settings row, `config.uploadDir`.
- Produces: `submissionsRouter({ db, geo, mailer, getSettings, uploadDir })` mounting:
  - `POST /` (public, multipart or json) — accepts payload fields: `groupKey, groupLabel, tierKey, tierName, billing, dueToday, priceSnapshot(JSON str), name, business, email, phone, action, assessment, answers(JSON str), sourceUrl, utm(JSON str)` + optional file field `csv`.
  - `GET /?group=&status=&from=&to=&q=` (auth) — returns `{ submissions: [...] }`.
  - `GET /:id` (auth) — `{ submission }` with `answers`/`price_snapshot`/`utm` parsed.
  - `PATCH /:id` (auth) — `{ status }` in {new,contacted,closed}.
  - `GET /:id/file` (auth) — streams CSV.

- [ ] **Step 1: Create `admin/api/src/middleware/upload.js`**

```js
import multer from "multer";
import fs from "node:fs";
import path from "node:path";
import { config } from "../config.js";

fs.mkdirSync(config.uploadDir, { recursive: true });

const storage = multer.diskStorage({
  destination: (_req, _file, cb) => cb(null, config.uploadDir),
  filename: (_req, file, cb) => {
    const safe = file.originalname.replace(/[^\w.\-]/g, "_").slice(-80);
    cb(null, `${Date.now()}-${Math.round(Math.random() * 1e6)}-${safe}`);
  },
});

export const uploadCsv = multer({
  storage,
  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
  fileFilter: (_req, file, cb) => {
    const ok = /csv|text\/plain|application\/vnd\.ms-excel/.test(file.mimetype) ||
               file.originalname.toLowerCase().endsWith(".csv");
    cb(ok ? null : new Error("Only CSV files allowed"), ok);
  },
}).single("csv");
```

- [ ] **Step 2: Create `admin/api/src/routes/submissions.js`**

```js
import { Router } from "express";
import path from "node:path";
import fs from "node:fs";
import { requireAuth } from "../middleware/auth.js";
import { uploadCsv } from "../middleware/upload.js";
import { clientIp } from "../services/geo.js";
import { config } from "../config.js";

const STATUSES = ["new", "contacted", "closed"];

// multipart sends scalar strings; json sends typed. Normalize both.
function pick(body, file) {
  const num = (v) => (v === "" || v == null ? null : Number(v));
  const bool = (v) => v === true || v === "true" || v === 1 || v === "1" ? 1 : 0;
  return {
    group_key: body.groupKey, group_label: body.groupLabel,
    tier_key: body.tierKey, tier_name: body.tierName,
    billing: body.billing, due_today: num(body.dueToday),
    price_snapshot: typeof body.priceSnapshot === "string" ? body.priceSnapshot : JSON.stringify(body.priceSnapshot ?? null),
    name: body.name || null, business: body.business || null,
    email: body.email || null, phone: body.phone || null,
    action: body.action || null, assessment: bool(body.assessment),
    answers: typeof body.answers === "string" ? body.answers : JSON.stringify(body.answers ?? {}),
    source_url: body.sourceUrl || null,
    utm: typeof body.utm === "string" ? body.utm : JSON.stringify(body.utm ?? null),
    csv_path: file ? path.basename(file.path) : null,
    csv_original_name: file ? file.originalname : null,
  };
}

export function submissionsRouter({ db, geo, mailer, getSettings }) {
  const r = Router();

  // --- public create ---
  r.post("/", (req, res) => {
    uploadCsv(req, res, async (err) => {
      if (err) return res.status(400).json({ error: err.message });
      const b = req.body || {};
      if (!b.tierKey || !b.groupKey) return res.status(400).json({ error: "Missing package" });
      if (!b.email && !b.phone) return res.status(400).json({ error: "Email or phone required" });

      const row = pick(b, req.file);
      const ip = clientIp(req, config.trustProxy);
      const g = geo.lookup(ip);
      row.ip = ip || null;
      row.geo_city = g.city; row.geo_region = g.region; row.geo_country = g.country;
      row.geo_lat = g.lat; row.geo_lon = g.lon;

      const cols = Object.keys(row);
      const info = db.prepare(
        `INSERT INTO submissions (${cols.join(",")}) VALUES (${cols.map(() => "?").join(",")})`
      ).run(...cols.map((c) => row[c]));

      // fire emails — never block the saved lead
      const saved = db.prepare("SELECT * FROM submissions WHERE id=?").get(info.lastInsertRowid);
      mailer.sendSubmissionEmails(saved, getSettings()).catch((e) => console.error(e));

      res.status(201).json({ id: info.lastInsertRowid });
    });
  });

  // --- admin (auth) ---
  r.use(requireAuth());

  r.get("/", (req, res) => {
    const { group, status, from, to, q } = req.query;
    const where = [], args = [];
    if (group) { where.push("group_key=?"); args.push(group); }
    if (status) { where.push("status=?"); args.push(status); }
    if (from) { where.push("created_at>=?"); args.push(from); }
    if (to) { where.push("created_at<=?"); args.push(to); }
    if (q) { where.push("(name LIKE ? OR business LIKE ? OR email LIKE ?)"); args.push(`%${q}%`, `%${q}%`, `%${q}%`); }
    const sql = `SELECT * FROM submissions ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY created_at DESC`;
    res.json({ submissions: db.prepare(sql).all(...args) });
  });

  r.get("/:id", (req, res) => {
    const s = db.prepare("SELECT * FROM submissions WHERE id=?").get(req.params.id);
    if (!s) return res.status(404).json({ error: "Not found" });
    const parse = (v) => { try { return JSON.parse(v); } catch { return v; } };
    s.answers = parse(s.answers); s.price_snapshot = parse(s.price_snapshot); s.utm = parse(s.utm);
    res.json({ submission: s });
  });

  r.patch("/:id", (req, res) => {
    const { status } = req.body || {};
    if (!STATUSES.includes(status)) return res.status(400).json({ error: "Invalid status" });
    const info = db.prepare("UPDATE submissions SET status=? WHERE id=?").run(status, req.params.id);
    if (!info.changes) return res.status(404).json({ error: "Not found" });
    res.json({ ok: true });
  });

  r.get("/:id/file", (req, res) => {
    const s = db.prepare("SELECT csv_path, csv_original_name FROM submissions WHERE id=?").get(req.params.id);
    if (!s || !s.csv_path) return res.status(404).json({ error: "No file" });
    const full = path.join(config.uploadDir, s.csv_path);
    if (!fs.existsSync(full)) return res.status(404).json({ error: "Missing on disk" });
    res.download(full, s.csv_original_name || "list.csv");
  });

  return r;
}
```

- [ ] **Step 3: Write failing test `admin/api/test/submissions.test.js`**

```js
import { describe, it, expect, beforeEach, vi } from "vitest";
import request from "supertest";
import express from "express";
import { openDb } from "../src/db.js";
import { seedAdmin, seedSettings } from "../src/seed.js";
import { authRouter } from "../src/routes/auth.js";
import { submissionsRouter } from "../src/routes/submissions.js";
import { createGeo } from "../src/services/geo.js";

function build() {
  const db = openDb(":memory:");
  seedAdmin(db, { email: "admin@btwebgroup.com", password: "admin123" });
  seedSettings(db);
  const mailer = { sendSubmissionEmails: vi.fn().mockResolvedValue({ autoresponder: true, notify: true }) };
  const geo = createGeo({ get: () => ({ city: { names: { en: "Lexington" } }, country: { names: { en: "United States" } } }) });
  const getSettings = () => db.prepare("SELECT * FROM settings WHERE id=1").get();
  const a = express(); a.use(express.json());
  a.use("/api/auth", authRouter(db));
  a.use("/api/submissions", submissionsRouter({ db, geo, mailer, getSettings }));
  return { a, db, mailer };
}
async function token(a) {
  const r = await request(a).post("/api/auth/login").send({ email: "admin@btwebgroup.com", password: "admin123" });
  return r.body.token;
}
const payload = {
  groupKey: "service-seo", groupLabel: "SEO", tierKey: "service-seo-growth", tierName: "Growth",
  billing: "recurring", dueToday: 997, priceSnapshot: JSON.stringify({ price: 997 }),
  name: "Sam", business: "Acme", email: "sam@x.com", phone: "859",
  action: "callback", assessment: true, answers: JSON.stringify({ website_url: "x.com" }),
};

describe("submissions", () => {
  let a, db, mailer;
  beforeEach(() => { ({ a, db, mailer } = build()); });

  it("creates a submission (json), geolocates, fires emails", async () => {
    const res = await request(a).post("/api/submissions").send(payload);
    expect(res.status).toBe(201);
    expect(res.body.id).toBeTruthy();
    const row = db.prepare("SELECT * FROM submissions WHERE id=?").get(res.body.id);
    expect(row.geo_city).toBe("Lexington");
    expect(row.tier_name).toBe("Growth");
    expect(mailer.sendSubmissionEmails).toHaveBeenCalledOnce();
  });

  it("rejects missing package", async () => {
    const res = await request(a).post("/api/submissions").send({ name: "x", email: "a@b.com" });
    expect(res.status).toBe(400);
  });

  it("rejects when no email and no phone", async () => {
    const { email, phone, ...rest } = payload;
    const res = await request(a).post("/api/submissions").send(rest);
    expect(res.status).toBe(400);
  });

  it("lists and filters by group (auth)", async () => {
    await request(a).post("/api/submissions").send(payload);
    const t = await token(a);
    const res = await request(a).get("/api/submissions?group=service-seo").set("Authorization", `Bearer ${t}`);
    expect(res.body.submissions.length).toBe(1);
  });

  it("returns detail with parsed answers", async () => {
    const c = await request(a).post("/api/submissions").send(payload);
    const t = await token(a);
    const res = await request(a).get(`/api/submissions/${c.body.id}`).set("Authorization", `Bearer ${t}`);
    expect(res.body.submission.answers.website_url).toBe("x.com");
  });

  it("updates status", async () => {
    const c = await request(a).post("/api/submissions").send(payload);
    const t = await token(a);
    const res = await request(a).patch(`/api/submissions/${c.body.id}`).set("Authorization", `Bearer ${t}`).send({ status: "contacted" });
    expect(res.status).toBe(200);
    expect(db.prepare("SELECT status FROM submissions WHERE id=?").get(c.body.id).status).toBe("contacted");
  });

  it("blocks list without auth", async () => {
    expect((await request(a).get("/api/submissions")).status).toBe(401);
  });
});
```

- [ ] **Step 4: Run test**

Run: `cd admin/api && npm test -- submissions`
Expected: PASS (7 tests).

- [ ] **Step 5: Commit**

```bash
git add admin/api/src/middleware/upload.js admin/api/src/routes/submissions.js admin/api/test/submissions.test.js
git commit -m "feat(admin-api): submissions create/list/detail/patch/file routes"
```

---

### Task 9: Wire it all — bootstrap, mount routes, rate limit, CORS, seed on boot

**Files:**
- Create: `admin/api/src/bootstrap.js`
- Modify: `admin/api/src/server.js` (CORS config), `admin/api/test/health.test.js` (no change needed)
- Create: `admin/api/test/bootstrap.test.js`

**Interfaces:**
- Consumes: every router + service above.
- Produces: `wireApp(createApp)` -> a fully wired Express app using real db/geo/mailer; opens db at `config.dbPath`, seeds admin+settings, mounts routers under `/api/*`, applies CORS for `config.allowedOrigin`, rate-limits `POST /api/submissions`.

- [ ] **Step 1: Add CORS to `admin/api/src/server.js`**

Add near the top of `createApp`, after `const app = express();`:

```js
  app.use(cors({ origin: config.allowedOrigin, methods: ["GET", "POST", "PATCH"], allowedHeaders: ["Content-Type", "Authorization"] }));
```

(import `cors` already present from Task 1.)

- [ ] **Step 2: Create `admin/api/src/bootstrap.js`**

```js
import rateLimit from "express-rate-limit";
import { config } from "./config.js";
import { openDb } from "./db.js";
import { seedAdmin, seedSettings } from "./seed.js";
import { openReader, createGeo } from "./services/geo.js";
import { createMailer } from "./services/mailer.js";
import { authRouter } from "./routes/auth.js";
import { settingsRouter } from "./routes/settings.js";
import { submissionsRouter } from "./routes/submissions.js";

export function wireApp(createApp) {
  const db = openDb(config.dbPath);
  seedAdmin(db, { email: config.seedAdminEmail.toLowerCase(), password: config.seedAdminPassword });
  seedSettings(db);

  let geo = createGeo(null);
  openReader(config.geoipDb).then((reader) => { if (reader) geo = createGeo(reader); });

  const mailer = createMailer({ apiKey: config.sendgridKey });
  const getSettings = () => db.prepare("SELECT * FROM settings WHERE id=1").get();

  const submitLimiter = rateLimit({ windowMs: 60_000, max: 20 });

  return createApp({
    mountRoutes(app) {
      app.use("/api/auth", authRouter(db));
      app.use("/api/settings", settingsRouter(db));
      // limiter only on the public create; admin GET/PATCH not throttled
      app.use("/api/submissions", (req, res, next) =>
        req.method === "POST" ? submitLimiter(req, res, next) : next());
      app.use("/api/submissions", submissionsRouter({ db, geo: { lookup: (ip) => geo.lookup(ip) }, mailer, getSettings }));
    },
  });
}
```

> `geo` is wrapped in a closure (`(ip) => geo.lookup(ip)`) so the async reader load swaps in without re-mounting.

- [ ] **Step 3: Write test `admin/api/test/bootstrap.test.js`**

```js
import { describe, it, expect } from "vitest";
import request from "supertest";
import { createApp } from "../src/server.js";
import { wireApp } from "../src/bootstrap.js";

describe("bootstrap", () => {
  it("boots a wired app with health + auth mounted", async () => {
    const app = wireApp(createApp);
    expect((await request(app).get("/api/health")).status).toBe(200);
    // seeded admin can log in against the real (file) db
    const res = await request(app).post("/api/auth/login").send({ email: "admin@btwebgroup.com", password: "admin123" });
    expect(res.status).toBe(200);
  });
});
```

> This test writes to `config.dbPath` (the real data dir). Acceptable; the dir is gitignored. Alternatively set `DB_PATH=:memory:` via env in CI.

- [ ] **Step 4: Run full api suite**

Run: `cd admin/api && npm test`
Expected: all PASS.

- [ ] **Step 5: Manual smoke**

Run: `cd admin/api && cp .env.example .env && npm start`
Expected: logs `API on :4000` and a `[geo] GeoLite2 DB not found` warning. `curl localhost:4000/api/health` -> `{"ok":true}`. Ctrl-C.

- [ ] **Step 6: Commit**

```bash
git add admin/api/src/bootstrap.js admin/api/src/server.js admin/api/test/bootstrap.test.js
git commit -m "feat(admin-api): bootstrap wiring, CORS, rate limiting, boot seed"
```

---

### Task 10: Web scaffold + tokens + API client + auth context

**Files:**
- Create: `admin/web/package.json`, `admin/web/vite.config.js`, `admin/web/index.html`, `admin/web/src/main.jsx`, `admin/web/src/tokens.css`, `admin/web/src/api.js`, `admin/web/src/auth.jsx`, `admin/web/src/App.jsx`, `admin/web/test/api.test.js`

**Interfaces:**
- Produces: `api` object (`login`, `me`, `changePassword`, `listSubmissions`, `getSubmission`, `patchSubmission`, `getSettings`, `putSettings`, `fileUrl`); `AuthProvider`, `useAuth`, `RequireAuth`.

- [ ] **Step 1: Create `admin/web/package.json`**

```json
{
  "name": "btw-admin-web",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test": "vitest run"
  },
  "dependencies": {
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-router-dom": "^6.26.0"
  },
  "devDependencies": {
    "@testing-library/react": "^16.0.0",
    "@vitejs/plugin-react": "^4.3.1",
    "jsdom": "^25.0.0",
    "vite": "^5.4.0",
    "vitest": "^2.1.1"
  }
}
```

- [ ] **Step 2: Create `admin/web/vite.config.js`**

```js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
  plugins: [react()],
  test: { environment: "jsdom", globals: true },
});
```

- [ ] **Step 3: Create `admin/web/index.html`**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>BT Web Group — Admin</title>
    <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>
```

- [ ] **Step 4: Create `admin/web/src/tokens.css`** (BT Web Group palette + base)

```css
:root{
  --ink:#111827;--ink-soft:#1f2937;--body:#4b5563;--muted:#6b7280;--faint:#9ca3af;
  --gold:#fbb416;--gold-hover:#f0a500;--gold-deep:#946a00;--gold-tint:#fff6e0;
  --green:#6c9a2e;--green-dark:#5d8527;--green-deep:#4b6d1f;--green-tint:#f1f6e8;
  --bg:#ffffff;--bg-alt:#f7f8fa;--card:#ffffff;--border:#e6e8ec;--border-strong:#d4d8df;
  --radius:16px;--radius-sm:10px;--radius-pill:999px;
  --shadow-sm:0 1px 3px rgba(16,24,40,.07);--shadow-md:0 4px 14px rgba(16,24,40,.07);--shadow-lg:0 14px 36px rgba(16,24,40,.10);
  --font:'Montserrat',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
}
*{box-sizing:border-box;}
body{margin:0;font-family:var(--font);color:var(--body);background:var(--bg-alt);}
a{color:var(--green-deep);}
.btn{display:inline-flex;align-items:center;gap:8px;height:44px;padding:0 18px;border:0;border-radius:var(--radius-sm);
  font-family:var(--font);font-weight:700;font-size:14px;cursor:pointer;}
.btn-primary{background:var(--gold);color:var(--ink);}
.btn-primary:hover{background:var(--gold-hover);}
.btn-ghost{background:#fff;border:1.5px solid var(--green);color:var(--green-deep);}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-sm);}
.input,.textarea,.select{width:100%;padding:10px 12px;border:1px solid var(--border-strong);border-radius:var(--radius-sm);font-family:var(--font);font-size:14px;}
.label{display:block;font-size:13px;font-weight:700;color:var(--ink);margin:14px 0 6px;}
.wrap{max-width:1100px;margin:0 auto;padding:24px 20px;}
.err{color:#b42318;font-size:13px;margin-top:8px;}
```

- [ ] **Step 5: Create `admin/web/src/api.js`**

```js
const BASE = import.meta.env.VITE_API_URL || "http://localhost:4000";
let token = localStorage.getItem("btw_token") || null;

export function setToken(t) {
  token = t;
  if (t) localStorage.setItem("btw_token", t);
  else localStorage.removeItem("btw_token");
}
export function getToken() { return token; }

async function req(path, opts = {}) {
  const headers = { ...(opts.headers || {}) };
  if (token) headers.Authorization = `Bearer ${token}`;
  if (opts.json !== undefined) { headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(opts.json); }
  const res = await fetch(`${BASE}${path}`, { ...opts, headers });
  if (res.status === 401) { setToken(null); throw new Error("Unauthorized"); }
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
  return data;
}

export const api = {
  login: (email, password) => req("/api/auth/login", { method: "POST", json: { email, password } }),
  me: () => req("/api/auth/me"),
  changePassword: (currentPassword, newPassword) => req("/api/auth/change-password", { method: "POST", json: { currentPassword, newPassword } }),
  listSubmissions: (qs = "") => req(`/api/submissions${qs ? "?" + qs : ""}`),
  getSubmission: (id) => req(`/api/submissions/${id}`),
  patchSubmission: (id, status) => req(`/api/submissions/${id}`, { method: "PATCH", json: { status } }),
  getSettings: () => req("/api/settings"),
  putSettings: (body) => req("/api/settings", { method: "PUT", json: body }),
  fileUrl: (id) => `${BASE}/api/submissions/${id}/file`,
};
```

- [ ] **Step 6: Create `admin/web/src/auth.jsx`**

```jsx
import { createContext, useContext, useState } from "react";
import { Navigate } from "react-router-dom";
import { api, setToken, getToken } from "./api.js";

const Ctx = createContext(null);

export function AuthProvider({ children }) {
  const [authed, setAuthed] = useState(!!getToken());
  const [mustChange, setMustChange] = useState(false);

  async function login(email, password) {
    const { token, mustChangePassword } = await api.login(email, password);
    setToken(token);
    setAuthed(true);
    setMustChange(mustChangePassword);
    return mustChangePassword;
  }
  function logout() { setToken(null); setAuthed(false); }

  return <Ctx.Provider value={{ authed, mustChange, setMustChange, login, logout }}>{children}</Ctx.Provider>;
}

export function useAuth() { return useContext(Ctx); }

export function RequireAuth({ children }) {
  const { authed } = useAuth();
  return authed ? children : <Navigate to="/login" replace />;
}
```

- [ ] **Step 7: Create `admin/web/src/App.jsx`** (routes; pages stubbed, filled in later tasks)

```jsx
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider, RequireAuth } from "./auth.jsx";
import Login from "./pages/Login.jsx";
import Dashboard from "./pages/Dashboard.jsx";
import SubmissionDetail from "./pages/SubmissionDetail.jsx";
import Settings from "./pages/Settings.jsx";

export default function App() {
  return (
    <AuthProvider>
      <BrowserRouter>
        <Routes>
          <Route path="/login" element={<Login />} />
          <Route path="/" element={<RequireAuth><Dashboard /></RequireAuth>} />
          <Route path="/submissions/:id" element={<RequireAuth><SubmissionDetail /></RequireAuth>} />
          <Route path="/settings" element={<RequireAuth><Settings /></RequireAuth>} />
          <Route path="*" element={<Navigate to="/" replace />} />
        </Routes>
      </BrowserRouter>
    </AuthProvider>
  );
}
```

- [ ] **Step 8: Create `admin/web/src/main.jsx`**

```jsx
import React from "react";
import { createRoot } from "react-dom/client";
import "./tokens.css";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(<React.StrictMode><App /></React.StrictMode>);
```

- [ ] **Step 9: Write test `admin/web/test/api.test.js`** (token header injection)

```js
import { describe, it, expect, vi, beforeEach } from "vitest";
import { api, setToken } from "../src/api.js";

describe("api client", () => {
  beforeEach(() => { setToken(null); });

  it("attaches Bearer token after login", async () => {
    const fetchMock = vi.fn()
      .mockResolvedValueOnce({ status: 200, ok: true, json: async () => ({ token: "abc", mustChangePassword: true }) })
      .mockResolvedValueOnce({ status: 200, ok: true, json: async () => ({ user: { id: 1 } }) });
    vi.stubGlobal("fetch", fetchMock);

    await api.login("admin@btwebgroup.com", "admin123");
    setToken("abc");
    await api.me();

    const [, opts] = fetchMock.mock.calls[1];
    expect(opts.headers.Authorization).toBe("Bearer abc");
  });
});
```

- [ ] **Step 10: Create placeholder pages so the app compiles** (replaced in Tasks 11–13)

Create minimal `admin/web/src/pages/Login.jsx`, `Dashboard.jsx`, `SubmissionDetail.jsx`, `Settings.jsx` each exporting `export default function X(){ return null; }`. These are scaffolds; later tasks overwrite them fully.

- [ ] **Step 11: Install + test**

Run: `cd admin/web && npm install && npm test`
Expected: PASS (api client test).

- [ ] **Step 12: Commit**

```bash
git add admin/web
git commit -m "feat(admin-web): vite scaffold, tokens, api client, auth context"
```

---

### Task 11: Login page (+ forced password change)

**Files:**
- Modify: `admin/web/src/pages/Login.jsx`

**Interfaces:**
- Consumes: `useAuth().login`, `api.changePassword`.

- [ ] **Step 1: Implement `admin/web/src/pages/Login.jsx`**

```jsx
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth.jsx";
import { api } from "../api.js";

export default function Login() {
  const { login } = useAuth();
  const nav = useNavigate();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [stage, setStage] = useState("login"); // login | change
  const [np, setNp] = useState("");
  const [err, setErr] = useState("");

  async function submit(e) {
    e.preventDefault();
    setErr("");
    try {
      const must = await login(email, password);
      if (must) setStage("change"); else nav("/");
    } catch (ex) { setErr(ex.message); }
  }
  async function change(e) {
    e.preventDefault();
    setErr("");
    try { await api.changePassword(password, np); nav("/"); }
    catch (ex) { setErr(ex.message); }
  }

  return (
    <div className="wrap" style={{ maxWidth: 420 }}>
      <div className="card" style={{ padding: 28, marginTop: 80 }}>
        <h1 style={{ color: "var(--ink)", fontSize: 22 }}>BT Web Group Admin</h1>
        {stage === "login" ? (
          <form onSubmit={submit}>
            <label className="label">Email</label>
            <input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
            <label className="label">Password</label>
            <input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
            {err && <div className="err">{err}</div>}
            <button className="btn btn-primary" style={{ marginTop: 18, width: "100%" }}>Sign in</button>
          </form>
        ) : (
          <form onSubmit={change}>
            <p style={{ color: "var(--muted)", fontSize: 14 }}>Set a new password to continue.</p>
            <label className="label">New password (min 8 chars)</label>
            <input className="input" type="password" value={np} onChange={(e) => setNp(e.target.value)} required minLength={8} />
            {err && <div className="err">{err}</div>}
            <button className="btn btn-primary" style={{ marginTop: 18, width: "100%" }}>Save & continue</button>
          </form>
        )}
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Manual verify (deferred to Task 15 e2e smoke).** No unit test for this presentational form; logic (`login`, `changePassword`) is covered by api/auth tests. Verify it compiles:

Run: `cd admin/web && npm run build`
Expected: build succeeds.

- [ ] **Step 3: Commit**

```bash
git add admin/web/src/pages/Login.jsx
git commit -m "feat(admin-web): login + forced password change"
```

---

### Task 12: Dashboard — submissions grouped by package

**Files:**
- Create: `admin/web/src/group.js`, `admin/web/src/components/GroupCard.jsx`, `admin/web/src/components/StatusBadge.jsx`, `admin/web/test/group.test.js`
- Modify: `admin/web/src/pages/Dashboard.jsx`

**Interfaces:**
- Produces: `groupByPackage(rows)` -> `[{ groupKey, groupLabel, count, latest, items }]` sorted by `count` desc.

- [ ] **Step 1: Create `admin/web/src/group.js`**

```js
export function groupByPackage(rows) {
  const map = new Map();
  for (const r of rows) {
    if (!map.has(r.group_key)) {
      map.set(r.group_key, { groupKey: r.group_key, groupLabel: r.group_label, count: 0, latest: null, items: [] });
    }
    const g = map.get(r.group_key);
    g.items.push(r);
    g.count++;
    if (!g.latest || r.created_at > g.latest) g.latest = r.created_at;
  }
  return [...map.values()].sort((a, b) => b.count - a.count);
}
```

- [ ] **Step 2: Write failing test `admin/web/test/group.test.js`**

```js
import { describe, it, expect } from "vitest";
import { groupByPackage } from "../src/group.js";

describe("groupByPackage", () => {
  it("groups by group_key with counts and latest", () => {
    const rows = [
      { group_key: "seo", group_label: "SEO", created_at: "2026-01-01" },
      { group_key: "seo", group_label: "SEO", created_at: "2026-02-01" },
      { group_key: "ppc", group_label: "PPC", created_at: "2026-01-15" },
    ];
    const g = groupByPackage(rows);
    expect(g[0].groupKey).toBe("seo");
    expect(g[0].count).toBe(2);
    expect(g[0].latest).toBe("2026-02-01");
    expect(g[1].groupKey).toBe("ppc");
  });
});
```

- [ ] **Step 3: Run test**

Run: `cd admin/web && npm test -- group`
Expected: PASS.

- [ ] **Step 4: Create `admin/web/src/components/StatusBadge.jsx`**

```jsx
const COLORS = {
  new: ["#fff6e0", "#946a00"], contacted: ["#f1f6e8", "#4b6d1f"], closed: ["#eef1f5", "#4b5563"],
};
export default function StatusBadge({ status }) {
  const [bg, fg] = COLORS[status] || COLORS.closed;
  return <span style={{ background: bg, color: fg, padding: "3px 10px", borderRadius: 999, fontSize: 12, fontWeight: 700 }}>{status}</span>;
}
```

- [ ] **Step 5: Create `admin/web/src/components/GroupCard.jsx`**

```jsx
import { useState } from "react";
import { Link } from "react-router-dom";
import StatusBadge from "./StatusBadge.jsx";

export default function GroupCard({ group }) {
  const [open, setOpen] = useState(true);
  return (
    <div className="card" style={{ marginBottom: 16, overflow: "hidden" }}>
      <button onClick={() => setOpen(!open)}
        style={{ width: "100%", display: "flex", justifyContent: "space-between", alignItems: "center",
          padding: "16px 20px", background: "var(--green-tint)", border: 0, cursor: "pointer", fontFamily: "var(--font)" }}>
        <span style={{ fontWeight: 800, color: "var(--ink)", fontSize: 16 }}>{group.groupLabel}</span>
        <span style={{ color: "var(--muted)", fontSize: 13 }}>{group.count} submission{group.count !== 1 ? "s" : ""} · latest {group.latest?.slice(0, 10)}</span>
      </button>
      {open && (
        <table style={{ width: "100%", borderCollapse: "collapse" }}>
          <tbody>
            {group.items.map((s) => (
              <tr key={s.id} style={{ borderTop: "1px solid var(--border)" }}>
                <td style={{ padding: "12px 20px" }}>
                  <Link to={`/submissions/${s.id}`} style={{ fontWeight: 700 }}>{s.name || "—"}</Link>
                  <div style={{ fontSize: 12, color: "var(--muted)" }}>{s.business || ""}</div>
                </td>
                <td style={{ padding: "12px 8px", fontSize: 13 }}>{s.tier_name}</td>
                <td style={{ padding: "12px 8px", fontSize: 13 }}>{[s.geo_city, s.geo_country].filter(Boolean).join(", ") || "—"}</td>
                <td style={{ padding: "12px 8px", fontSize: 13 }}>{s.due_today != null ? `$${s.due_today.toLocaleString()}` : "—"}</td>
                <td style={{ padding: "12px 8px" }}><StatusBadge status={s.status} /></td>
                <td style={{ padding: "12px 20px", fontSize: 12, color: "var(--muted)" }}>{s.created_at?.slice(0, 16)}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </div>
  );
}
```

- [ ] **Step 6: Implement `admin/web/src/pages/Dashboard.jsx`**

```jsx
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api.js";
import { useAuth } from "../auth.jsx";
import { groupByPackage } from "../group.js";
import GroupCard from "../components/GroupCard.jsx";

export default function Dashboard() {
  const { logout } = useAuth();
  const [rows, setRows] = useState([]);
  const [status, setStatus] = useState("");
  const [q, setQ] = useState("");
  const [err, setErr] = useState("");

  async function load() {
    setErr("");
    const params = new URLSearchParams();
    if (status) params.set("status", status);
    if (q) params.set("q", q);
    try { const d = await api.listSubmissions(params.toString()); setRows(d.submissions); }
    catch (e) { setErr(e.message); }
  }
  useEffect(() => { load(); /* eslint-disable-next-line */ }, [status]);

  const groups = groupByPackage(rows);

  return (
    <div className="wrap">
      <header style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 20 }}>
        <h1 style={{ color: "var(--ink)", fontSize: 24 }}>Submissions</h1>
        <div style={{ display: "flex", gap: 10 }}>
          <Link className="btn btn-ghost" to="/settings">Settings</Link>
          <button className="btn btn-ghost" onClick={logout}>Log out</button>
        </div>
      </header>

      <div className="card" style={{ padding: 14, marginBottom: 18, display: "flex", gap: 10, flexWrap: "wrap" }}>
        <input className="input" style={{ flex: 1, minWidth: 200 }} placeholder="Search name, business, email" value={q}
          onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && load()} />
        <select className="select" style={{ width: 180 }} value={status} onChange={(e) => setStatus(e.target.value)}>
          <option value="">All statuses</option>
          <option value="new">New</option>
          <option value="contacted">Contacted</option>
          <option value="closed">Closed</option>
        </select>
        <button className="btn btn-primary" onClick={load}>Search</button>
      </div>

      {err && <div className="err">{err}</div>}
      {groups.length === 0 ? <p style={{ color: "var(--muted)" }}>No submissions yet.</p>
        : groups.map((g) => <GroupCard key={g.groupKey} group={g} />)}
    </div>
  );
}
```

- [ ] **Step 7: Run tests + build**

Run: `cd admin/web && npm test && npm run build`
Expected: PASS + build OK.

- [ ] **Step 8: Commit**

```bash
git add admin/web/src/group.js admin/web/src/components admin/web/src/pages/Dashboard.jsx admin/web/test/group.test.js
git commit -m "feat(admin-web): dashboard grouped by package"
```

---

### Task 13: Submission detail + Settings pages

**Files:**
- Modify: `admin/web/src/pages/SubmissionDetail.jsx`, `admin/web/src/pages/Settings.jsx`

**Interfaces:**
- Consumes: `api.getSubmission`, `api.patchSubmission`, `api.fileUrl`, `api.getSettings`, `api.putSettings`.

- [ ] **Step 1: Implement `admin/web/src/pages/SubmissionDetail.jsx`**

```jsx
import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
import { api } from "../api.js";
import StatusBadge from "../components/StatusBadge.jsx";

function Row({ k, v }) {
  if (v == null || v === "") return null;
  return <div style={{ display: "flex", gap: 12, padding: "8px 0", borderBottom: "1px solid var(--border)" }}>
    <div style={{ width: 200, color: "var(--muted)", fontSize: 13, fontWeight: 600 }}>{k}</div>
    <div style={{ flex: 1, color: "var(--ink)", fontSize: 14 }}>{Array.isArray(v) ? v.join(", ") : String(v)}</div>
  </div>;
}

export default function SubmissionDetail() {
  const { id } = useParams();
  const [s, setS] = useState(null);
  const [err, setErr] = useState("");

  async function load() { try { const d = await api.getSubmission(id); setS(d.submission); } catch (e) { setErr(e.message); } }
  useEffect(() => { load(); /* eslint-disable-next-line */ }, [id]);
  async function setStatus(status) { await api.patchSubmission(id, status); load(); }

  if (err) return <div className="wrap"><div className="err">{err}</div></div>;
  if (!s) return <div className="wrap">Loading…</div>;

  const loc = [s.geo_city, s.geo_region, s.geo_country].filter(Boolean).join(", ");

  return (
    <div className="wrap" style={{ maxWidth: 820 }}>
      <Link to="/" style={{ fontSize: 13 }}>← Back to submissions</Link>
      <div className="card" style={{ padding: 24, marginTop: 12 }}>
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
          <h1 style={{ color: "var(--ink)", fontSize: 22 }}>{s.name} <span style={{ color: "var(--muted)", fontWeight: 500, fontSize: 15 }}>{s.business}</span></h1>
          <StatusBadge status={s.status} />
        </div>
        <div style={{ display: "flex", gap: 8, margin: "14px 0" }}>
          {["new", "contacted", "closed"].map((st) => (
            <button key={st} className="btn btn-ghost" style={{ height: 36, opacity: s.status === st ? 1 : 0.6 }} onClick={() => setStatus(st)}>{st}</button>
          ))}
        </div>

        <h3 style={{ color: "var(--ink)", marginTop: 18 }}>Package</h3>
        <Row k="Package" v={`${s.group_label} · ${s.tier_name}`} />
        <Row k="Billing" v={s.billing} />
        <Row k="Due today" v={s.due_today != null ? `$${s.due_today.toLocaleString()}` : null} />
        <Row k="Action" v={s.action} />
        <Row k="Free assessment kept" v={s.assessment ? "Yes" : "No"} />

        <h3 style={{ color: "var(--ink)", marginTop: 18 }}>Contact</h3>
        <Row k="Email" v={s.email} />
        <Row k="Phone" v={s.phone} />
        <Row k="Location" v={loc} />
        <Row k="IP" v={s.ip} />

        <h3 style={{ color: "var(--ink)", marginTop: 18 }}>Answers</h3>
        {s.answers && typeof s.answers === "object"
          ? Object.entries(s.answers).map(([k, v]) => <Row key={k} k={k} v={v} />)
          : <p style={{ color: "var(--muted)" }}>None</p>}

        {s.csv_path && (
          <p style={{ marginTop: 16 }}>
            <a className="btn btn-primary" href={api.fileUrl(s.id) + `?t=${encodeURIComponent(localStorage.getItem("btw_token") || "")}`}>Download CSV ({s.csv_original_name})</a>
          </p>
        )}
        <p style={{ color: "var(--muted)", fontSize: 12, marginTop: 18 }}>Submitted {s.created_at} · from {s.source_url || "unknown page"}</p>
      </div>
    </div>
  );
}
```

> Note: file download adds the token as a query param fallback because `<a href>` cannot send an Authorization header. Update `submissions.js` file route (Task 8) to also accept `?t=` — see Step 2.

- [ ] **Step 2: Allow token via query for the file download** — modify `admin/api/src/routes/submissions.js` file route to authenticate by header OR `?t=` query.

Replace the `r.get("/:id/file", ...)` handler. Move it ABOVE `r.use(requireAuth())` and authenticate inline:

```js
  // file download: header OR ?t= query token (so <a href> works)
  r.get("/:id/file", (req, res, next) => {
    const q = req.query.t;
    if (q) { req.headers.authorization = `Bearer ${q}`; }
    return requireAuth()(req, res, next);
  }, (req, res) => {
    const s = db.prepare("SELECT csv_path, csv_original_name FROM submissions WHERE id=?").get(req.params.id);
    if (!s || !s.csv_path) return res.status(404).json({ error: "No file" });
    const full = path.join(config.uploadDir, s.csv_path);
    if (!fs.existsSync(full)) return res.status(404).json({ error: "Missing on disk" });
    res.download(full, s.csv_original_name || "list.csv");
  });
```

Remove the old file route that was below `requireAuth()`. Re-run Task 8 tests to confirm still green: `cd admin/api && npm test -- submissions`.

- [ ] **Step 3: Implement `admin/web/src/pages/Settings.jsx`**

```jsx
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api.js";

const FIELDS = [
  ["admin_notify_email", "Admin notification email", "input"],
  ["from_email", "From email (SendGrid verified sender)", "input"],
  ["from_name", "From name", "input"],
  ["autoresponder_subject", "Auto-responder subject", "input"],
  ["autoresponder_body", "Auto-responder body", "textarea"],
  ["notify_subject", "Admin notify subject", "input"],
  ["notify_body", "Admin notify body", "textarea"],
];
const TOKENS = "{{name}} {{business}} {{email}} {{phone}} {{package}} {{tier}} {{price}} {{action}} {{location}} {{date}} {{answers}}";

export default function Settings() {
  const [form, setForm] = useState(null);
  const [msg, setMsg] = useState("");
  const [err, setErr] = useState("");

  useEffect(() => { api.getSettings().then((d) => setForm(d.settings || {})).catch((e) => setErr(e.message)); }, []);
  function set(k, v) { setForm((f) => ({ ...f, [k]: v })); }
  async function save(e) {
    e.preventDefault(); setMsg(""); setErr("");
    try { await api.putSettings(form); setMsg("Saved."); } catch (ex) { setErr(ex.message); }
  }
  if (!form) return <div className="wrap">Loading…</div>;

  return (
    <div className="wrap" style={{ maxWidth: 760 }}>
      <Link to="/" style={{ fontSize: 13 }}>← Back</Link>
      <div className="card" style={{ padding: 24, marginTop: 12 }}>
        <h1 style={{ color: "var(--ink)", fontSize: 22 }}>Email settings</h1>
        <p style={{ color: "var(--muted)", fontSize: 13 }}>Available tokens: <code>{TOKENS}</code></p>
        <form onSubmit={save}>
          {FIELDS.map(([k, label, type]) => (
            <div key={k}>
              <label className="label">{label}</label>
              {type === "textarea"
                ? <textarea className="textarea" rows={8} value={form[k] || ""} onChange={(e) => set(k, e.target.value)} />
                : <input className="input" value={form[k] || ""} onChange={(e) => set(k, e.target.value)} />}
            </div>
          ))}
          {msg && <p style={{ color: "var(--green-deep)", marginTop: 12 }}>{msg}</p>}
          {err && <div className="err">{err}</div>}
          <button className="btn btn-primary" style={{ marginTop: 18 }}>Save settings</button>
        </form>
      </div>
    </div>
  );
}
```

- [ ] **Step 4: Build**

Run: `cd admin/web && npm run build`
Expected: build succeeds.

- [ ] **Step 5: Commit**

```bash
git add admin/web/src/pages/SubmissionDetail.jsx admin/web/src/pages/Settings.jsx admin/api/src/routes/submissions.js
git commit -m "feat(admin-web): submission detail + settings; token-query file download"
```

---

### Task 14: Wire checkout.html to POST real submissions

**Files:**
- Modify: `packages-html/checkout.html` (CONFIG block + `finish()` function + add UTM capture)

**Interfaces:**
- Consumes: API `POST /api/submissions`.

- [ ] **Step 1: Add `API_URL` to the embedded `CO_CONFIG`** — locate the `CO_CONFIG` object opening (around the `var CO_CONFIG = {` line) and add a sibling config constant just above the IIFE's `run` usage. Simplest: add near the top of the checkout `<script>` IIFE, right after `'use strict';`:

```js
  /* Set this to the deployed API origin. CORS must allow this page's origin. */
  var API_URL = (window.BTW_API_URL || 'http://localhost:4000');
```

(Allows overriding per environment by defining `window.BTW_API_URL` before the script, e.g. in WordPress.)

- [ ] **Step 2: Add UTM + source capture helper** — add inside the IIFE near `getParam`:

```js
  function collectUtm() {
    var out = {}, names = ['utm_source','utm_medium','utm_campaign','utm_term','utm_content'];
    names.forEach(function (n) { var v = getParam(n); if (v) out[n] = v; });
    return out;
  }
```

- [ ] **Step 3: Replace `finish(kind)`** — rewrite the stub so it POSTs before showing the done pane. Replace the entire existing `function finish(kind) { ... }` body with:

```js
    function finish(kind) {
      collect(panes[2]);
      state.data.action = kind;

      /* find an actual file input in step 2 (if any) for multipart upload */
      var fileInput = panes[2].querySelector('input[type=file]');
      var file = fileInput && fileInput.files && fileInput.files[0] ? fileInput.files[0] : null;

      var g = state.group, t = state.tier;
      var base = {
        groupKey: state.groupKey, groupLabel: g.label,
        tierKey: t.key, tierName: t.name, billing: g.billing,
        dueToday: dueToday(g, t),
        priceSnapshot: JSON.stringify(t),
        name: state.data.name || '', business: state.data.business || '',
        email: state.data.email || '', phone: state.data.phone || '',
        action: kind, assessment: state.assessment ? '1' : '0',
        answers: JSON.stringify(answersOnly(state.data)),
        sourceUrl: window.location.href,
        utm: JSON.stringify(collectUtm())
      };

      var body, headers = {};
      if (file) {
        body = new FormData();
        Object.keys(base).forEach(function (k) { body.append(k, base[k]); });
        body.append('csv', file);
      } else {
        body = JSON.stringify(base);
        headers['Content-Type'] = 'application/json';
      }

      /* show the confirmation regardless of network result — never trap the user */
      function done() { showDone(kind); }
      if (window.fetch) {
        fetch(API_URL + '/api/submissions', { method: 'POST', headers: headers, body: body })
          .then(done).catch(function () { done(); });
      } else { done(); }
    }

    /* contact keys live in state.data alongside answers; strip them out for `answers` */
    function answersOnly(data) {
      var skip = { name: 1, business: 1, email: 1, phone: 1, action: 1 };
      var out = {};
      Object.keys(data).forEach(function (k) { if (!skip[k]) out[k] = data[k]; });
      return out;
    }

    /* the original done-pane copy, extracted so finish() can call it post-POST */
    function showDone(kind) {
      var first = (state.data.name || '').trim().split(' ')[0] || 'there';
      var phone = state.data.phone || 'the number you provided';
      var assess = state.assessment ? ' Your free $500 marketing assessment is reserved for you.' : '';
      if (kind === 'pay') {
        $('#doneH').textContent = 'Thanks, ' + first + ' — payment step is next';
        $('#doneSub').innerHTML = 'In the live version this opens secure Stripe checkout for <strong>' + esc(money(dueToday(state.group, state.tier))) + '</strong>. Your details for <strong>' + esc(state.tier.name) + '</strong> are saved. We’ll confirm your kickoff by email.' + assess;
      } else {
        $('#doneH').textContent = 'Got it, ' + first + ' — we’ll call you';
        $('#doneSub').innerHTML = 'No payment taken. A BT Web Group specialist will reach out at <strong>' + esc(phone) + '</strong> within 1 business day to talk through <strong>' + esc(state.tier.name) + '</strong>.' + assess;
      }
      show('done');
    }
```

> This keeps every existing UX string; it only adds the network POST and splits the done-rendering into `showDone`. `dueToday`, `money`, `esc`, `show`, `$`, `collect`, `panes`, `state` are all already in scope in the IIFE.

- [ ] **Step 4: Mirror `API_URL` note in the JSON-vs-embedded config comment** — no functional change to `checkout-packages.json` (it has no API field; `API_URL` is page-level). Add a one-line HTML comment near the top `<!-- ... -->` of checkout.html documenting: set `window.BTW_API_URL` before this script in production.

- [ ] **Step 5: Manual end-to-end smoke** (requires API running from Task 9):

```
Terminal A: cd admin/api && npm start
Terminal B: cd admin/web && npm run dev
Browser 1: open packages-html/checkout.html via a local static server
           (e.g. `npx serve packages-html`) at ?pkg=service-seo-growth,
           complete all 3 steps, click "Pay" or "Request callback".
Browser 2: open the web dev URL, log in admin@btwebgroup.com / admin123,
           change password, confirm the submission appears under "SEO",
           open detail, verify location + answers.
```
Expected: submission visible in panel; API logs two `[mailer]` attempts (they fail without a real SendGrid key — that is fine and must NOT have blocked the submission).

> CORS for local: set `ALLOWED_ORIGIN` in `admin/api/.env` to the static server origin (e.g. `http://localhost:3000`), and `window.BTW_API_URL` or the `API_URL` default to `http://localhost:4000`.

- [ ] **Step 6: Commit**

```bash
git add packages-html/checkout.html
git commit -m "feat(checkout): POST submissions to admin API with CSV + UTM"
```

---

### Task 15: README + .env.example completeness + final full-suite run

**Files:**
- Create: `admin/README.md`
- Verify: `admin/api/.env.example` (Task 1) covers all vars.

- [ ] **Step 1: Create `admin/README.md`**

````markdown
# BT Web Group — Submissions Admin

Two services:
- `api/` — Express + SQLite. Receives checkout submissions, geolocates by IP, emails via SendGrid, serves the admin panel data.
- `web/` — Vite + React admin panel.

## API setup
```bash
cd api
cp .env.example .env        # fill JWT_SECRET, SENDGRID_API_KEY, ALLOWED_ORIGIN
npm install
npm run seed                # creates admin@btwebgroup.com / admin123 + default settings
npm start                   # http://localhost:4000
```
First login forces a password change (seed password is intentionally weak).

### Geo-IP (optional but recommended)
Download MaxMind **GeoLite2-City.mmdb** (free account at maxmind.com) and place it at `api/data/GeoLite2-City.mmdb` (or set `GEOIP_DB`). Without it, submissions still save; location fields are left blank.

### Email
Uses SendGrid HTTP API. Set `SENDGRID_API_KEY` and a **verified sender** as `from_email` in the panel Settings. Edit templates + admin-notify address in Settings. Without a key, submissions still save; emails are skipped.

## Web setup
```bash
cd web
echo "VITE_API_URL=http://localhost:4000" > .env   # point at the API origin
npm install
npm run dev          # http://localhost:5173
npm run build        # production build -> web/dist
```

## Connecting checkout.html (production)
In WordPress/Beaver Builder, before the checkout `<script>`, define the API origin:
```html
<script>window.BTW_API_URL = "https://api.yourdomain.com";</script>
```
Set the API's `ALLOWED_ORIGIN` to `https://btwebgroup.com` so CORS permits the POST.

## Tests
```bash
cd api && npm test
cd web && npm test
```
````

- [ ] **Step 2: Run both full suites**

Run: `cd admin/api && npm test && cd ../web && npm test`
Expected: all PASS.

- [ ] **Step 3: Build the web app**

Run: `cd admin/web && npm run build`
Expected: `web/dist` produced, no errors.

- [ ] **Step 4: Commit**

```bash
git add admin/README.md
git commit -m "docs(admin): setup README"
```

---

## Self-Review

**Spec coverage:**
- §2 API scope (lead + admin + dual email + configurable templates/admin email) → Tasks 5, 6, 7, 8. ✓
- §2 SQLite storage → Task 2. ✓
- §2 JWT email+password auth → Task 3. ✓
- §2 IP geolocation → Task 4 + wired Task 8/9. ✓
- §2 configurable API URL + CORS → Task 9 (CORS), Task 14 (`API_URL`). ✓
- §2 CSV upload + download → Task 8 (multer + file route), Task 13 (download). ✓
- §2 SendGrid → Task 6. ✓
- §2 seeded first user + forced change → Task 3 (seed, must_change_pw), Task 11 (forced change UI). ✓
- §4 DB schema → Task 2. ✓
- §5 endpoints → Tasks 3,7,8. ✓
- §6 email tokens + defaults + CAN-SPAM + failure-safe → Task 5, 6. ✓
- §7 geo failure-safe + mmdb optional → Task 4, 9. ✓
- §8 checkout change (API_URL, finish POST, UTM, file, paste-safe) → Task 14. ✓
- §9 four screens → Tasks 11,12,13. ✓
- §10 security (bcrypt, jwt, rate limit, upload limits, files outside web root, gitignore) → Tasks 1,3,8,9. ✓
- §11 out-of-scope respected (no Stripe/multiuser/GPS/analytics). ✓

**Placeholder scan:** Task 10 Step 10 creates intentional stub pages, each fully overwritten in Tasks 11–13 — not a hidden placeholder; called out explicitly. No "TBD"/"add error handling"-style gaps; all code shown.

**Type consistency:** `createGeo(reader).lookup`, `clientIp`, `createMailer({apiKey,sender}).sendSubmissionEmails`, `submissionsRouter({db,geo,mailer,getSettings})`, `groupByPackage` shape, `api.*` method names — all consistent between definition and use. Geo passed to `submissionsRouter` in Task 8 test is a `createGeo(...)` object (`.lookup`); Task 9 passes `{ lookup: (ip)=>geo.lookup(ip) }` — same `.lookup` interface. ✓
