# Streamlink v2 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:** Build the Streamlink v2 customer portal: a Next.js app on AWS with its own Postgres read model synced from Salesforce, magic-link auth, account-scoped dashboards (sites/orders, billing, support), and one write path (service requests → SF Case).

**Architecture:** A sync worker polls Salesforce every 2 minutes for `SystemModstamp` deltas on nine objects and upserts *only allowlisted fields* into portal Postgres; the Next.js app reads Postgres exclusively. Service requests persist locally first, then push to Salesforce as Cases via a staged, idempotent queue. Auth is passwordless magic links keyed to synced Contact emails; every domain query takes `accountId` from the session as its first argument.

**Tech Stack:** Node 20, Next.js ^14.2 (App Router), TypeScript strict, Prisma ^5.19 + PostgreSQL, Tailwind CSS, vitest, Playwright, pino, `@aws-sdk/client-ses`, `@aws-sdk/client-secrets-manager`, esbuild (Lambda bundling). Salesforce via plain `fetch` REST (API v59.0), OAuth 2.0 Client Credentials.

**Spec:** `docs/superpowers/specs/2026-07-16-streamlink-v2-design.md` (authority on features). **Data reference:** `docs/design-input-brief.md` (authority on SF field names/filters).

## Global Constraints

- TypeScript `strict: true`; no `any` in committed code.
- All portal tables snake_case via Prisma `@@map`/`@map`; SF 18-char Ids are the PKs of mirror tables.
- Money columns: `Decimal @db.Decimal(12, 2)`. SF date fields → `@db.Date`; datetimes → `timestamptz`.
- **Vendor/margin/cost/card-sensitive fields must never appear in code except in `lib/sync/denylist.ts`.** The leak test (Task 4) enforces allowlist ∩ denylist = ∅.
- Every Prisma query for customer data must filter by `accountId` — domain functions take `accountId: string` as their first parameter, sourced only from the session.
- `SF_MODE` env: `mock` (default in dev/test — no network) | `real`. `EMAIL_MODE`: `log` (default) | `ses`.
- Secrets only via `getSecret()` (Task 3) — never `process.env` directly outside `lib/secrets.ts`, never inlined via `next.config` `env{}`.
- SOQL only via the tagged template `soql` (Task 4) — no string concatenation of user input into queries.
- Salesforce REST API version: `v59.0`. Incremental reads use the `queryAll` endpoint (captures `IsDeleted=true`).
- Conventional commits (`feat:`, `test:`, `chore:`). Commit at the end of every task; TDD within every task.
- Tests: `npm test` = vitest run. Unit tests in `tests/unit/`, integration in `tests/integration/`, e2e in `tests/e2e/`.

## File Structure

```
streamlink/
├── package.json  tsconfig.json  next.config.mjs  tailwind.config.ts  postcss.config.mjs
├── vitest.config.ts  playwright.config.ts  .env.example  .gitignore  README.md
├── prisma/schema.prisma
├── middleware.ts                     # security headers + cheap cookie redirect
├── app/
│   ├── layout.tsx  globals.css  page.tsx        # / → redirect /dashboard or /login
│   ├── login/page.tsx  login/sent/page.tsx
│   ├── select-account/page.tsx
│   ├── auth/verify/route.ts  auth/logout/route.ts
│   ├── api/auth/request-link/route.ts
│   ├── api/service-requests/route.ts
│   └── (portal)/layout.tsx                      # nav, account switcher, staleness banner
│       ├── dashboard/page.tsx
│       ├── sites/page.tsx
│       ├── orders/[id]/page.tsx
│       ├── billing/page.tsx
│       └── support/page.tsx  support/new/page.tsx  support/[id]/page.tsx
├── lib/
│   ├── db.ts  secrets.ts  logger.ts
│   ├── salesforce/auth.ts  client.ts  soql.ts  mock.ts
│   ├── sync/fieldAllowlist.ts  denylist.ts  mappers.ts  runSync.ts  backfill.ts
│   ├── mapping/status.ts
│   ├── auth/magicLink.ts  session.ts  email.ts
│   ├── domain/dashboard.ts  orders.ts  billing.ts  support.ts
│   └── serviceRequests/create.ts  push.ts
├── jobs/sync.ts  backfill.ts  drain.ts           # Lambda handlers + CLI entries
├── scripts/seed-dev.ts  build-lambdas.mjs
├── infra/README.md  amplify.yml
└── tests/unit/  tests/integration/  tests/e2e/
```

## Interface Contracts (authoritative — all tasks must match these exactly)

```ts
// lib/secrets.ts
export async function getSecret(name: string): Promise<string>          // env var first; AWS Secrets Manager fallback; throws if absent
// lib/db.ts
export const prisma: PrismaClient                                        // singleton
// lib/logger.ts
export const logger: pino.Logger

// lib/salesforce/soql.ts
export function soql(strings: TemplateStringsArray, ...values: (string | number | Date | string[])[]): string
// escapes ' and \ in strings, formats Dates as SOQL datetime literals (unquoted), joins string[] as ('a','b') lists

// lib/salesforce/client.ts
export type SfRecord = Record<string, unknown> & { attributes?: unknown; Id: string }
export interface SfClient {
  queryAll(soqlText: string): Promise<SfRecord[]>        // auto-follows nextRecordsUrl
  create(objectName: string, fields: Record<string, unknown>): Promise<string>  // returns new Id; throws SfError on failure
}
export function getSfClient(): Promise<SfClient>          // SF_MODE=mock → lib/salesforce/mock.ts implementation

// lib/sync/fieldAllowlist.ts
export const SYNC_OBJECTS = ['Account','Contact','Customer_Orders__c','Order_Line_Item__c','Case','Transaction__c','Netsuite_Transactions__c','Vendor_Delivery_Notification__c','Credit_Card__c'] as const
export type SyncObject = typeof SYNC_OBJECTS[number]
export const ALLOWLIST: Record<SyncObject, readonly string[]>            // exact SF field API names (relationship fields like 'Order__r.Account__c' allowed)
export function selectClause(obj: SyncObject): string                    // ALLOWLIST[obj].join(', ')
export function whereClause(obj: SyncObject): string                     // static per-object filters ('' if none) — e.g. Case record-type/origin slice, Account record-type slice, Netsuite type filter
// lib/sync/denylist.ts
export const DENYLIST: readonly string[]                                 // every vendor/margin/cost/card-sensitive field from the design brief

// lib/mapping/status.ts
export type UnitStatus = 'scheduled' | 'delivered' | 'removal_requested' | 'completed' | 'cancelled'
export function mapOrderStatus(raw: string | null): 'open' | 'closed'    // 'Open'→'open'; anything else (Closed, Audit, null)→'closed'
export function mapUnitStatus(input: { lifecycleRaw: string | null; deliveryConfirmed: boolean; cancelReason: string | null }): UnitStatus
export function mapCaseStatus(input: { statusRaw: string | null; closedAt: Date | null }): 'open' | 'closed'
export function isChargeAdjustment(recordTypeName: string | null): boolean  // 'Additional Tonnage' | 'Extended Rental' | 'Other Charges'

// lib/sync/mappers.ts — one per object; SfRecord → Prisma upsert payload
export function mapAccount(r: SfRecord): Prisma.AccountUncheckedCreateInput
export function mapContact(r: SfRecord): Prisma.ContactUncheckedCreateInput
export function mapOrder(r: SfRecord): Prisma.OrderUncheckedCreateInput
export function mapOrderLineItem(r: SfRecord): Prisma.OrderLineItemUncheckedCreateInput
export function mapCase(r: SfRecord): Prisma.CaseRecordUncheckedCreateInput
export function mapTransaction(r: SfRecord): Prisma.TransactionUncheckedCreateInput
export function mapInvoice(r: SfRecord): Prisma.InvoiceUncheckedCreateInput
export function mapDeliveryConfirmation(r: SfRecord): Prisma.DeliveryConfirmationUncheckedCreateInput
export function mapSavedCard(r: SfRecord): Prisma.SavedCardUncheckedCreateInput

// lib/sync/runSync.ts
export interface SyncResult { runId: string; status: 'success' | 'failed' | 'skipped_locked'; objects: Record<string, { upserted: number; error?: string }> }
export async function runIncrementalSync(objects?: SyncObject[], opts?: { maxRows?: number }): Promise<SyncResult>  // opts.maxRows appends LIMIT for backfill batching
// pg advisory lock key: hashtext('streamlink_sync'); per-object high-water mark in sync_state; one failed object doesn't abort others
export async function getLastSuccessfulSyncAt(): Promise<Date | null>

// lib/auth/magicLink.ts
export async function requestMagicLink(email: string, ip: string): Promise<void>  // silent no-op if email unknown/rate-limited (no enumeration)
export async function verifyMagicLink(rawToken: string): Promise<{ sessionToken: string; accountIds: string[] } | null>
// lib/auth/session.ts
export interface PortalSession { userId: string; email: string; accountId: string | null; accountName: string | null }
export const SESSION_COOKIE = 'sl_session'
export async function getSession(): Promise<PortalSession | null>        // reads cookie via next/headers
export async function requireSession(): Promise<PortalSession & { accountId: string }>  // redirect('/login') if none; redirect('/select-account') if accountId null
export async function selectAccount(sessionToken: string, accountId: string): Promise<boolean>  // validates membership via contacts table
export async function destroySession(): Promise<void>
export async function getEligibleAccounts(email: string): Promise<{ id: string; name: string }[]>
// lib/auth/email.ts
export async function sendMagicLinkEmail(to: string, link: string): Promise<void>  // EMAIL_MODE log|ses

// lib/domain/dashboard.ts
export interface DashboardData { activeUnits: number; sitesWithActiveUnits: number; openBalance: string; recentActivity: { at: Date; kind: 'delivery' | 'payment' | 'request'; text: string }[] }
export async function getDashboard(accountId: string): Promise<DashboardData>
// lib/domain/orders.ts
export interface SiteGroup { siteKey: string; siteLabel: string; orders: OrderSummary[] }
export interface OrderSummary { id: string; orderNumber: string; customerStatus: 'open' | 'closed'; siteLabel: string; deliveryDate: Date | null; balance: string | null; activeUnitCount: number }
export interface UnitDetail { id: string; jobType: string | null; product: string | null; size: string | null; quantity: number | null; status: UnitStatus; deliveryDate: Date | null; endDate: Date | null; lineTotal: string | null; adjustments: { recordType: string; lineTotal: string | null; description: string | null }[] }
export interface OrderDetail extends OrderSummary { units: UnitDetail[]; unattachedAdjustments: { recordType: string; lineTotal: string | null; description: string | null }[]; customerPo: string | null; notesToCustomer: string | null; totals: { total: string | null; tax: string | null; charged: string | null; balance: string | null } }
// adjustments attach to the order's single placement unit when there is exactly one; otherwise they land in unattachedAdjustments (SF has no parent-OLI link)
export function siteKeyFor(o: { storeName: string | null; storeNumber: string | null; siteAddress: string | null; shippingStreet: string | null; shippingCity: string | null; shippingState: string | null; shippingZip: string | null }): string
export async function getSites(accountId: string): Promise<SiteGroup[]>
export async function getOrderDetail(accountId: string, orderId: string): Promise<OrderDetail | null>   // null (→404) if not this account's
// lib/domain/billing.ts
export interface BillingData { openInvoices: { id: string; name: string; amountDue: string; dueDate: Date | null; status: string | null }[]; payments: { id: string; name: string; type: string; amount: string; cardLast4: string | null; at: Date; orderNumber: string | null }[]; cards: { label: string; last4: string | null; cardType: string | null; expiryDisplay: string | null; isPrimary: boolean }[]; totalOpenBalance: string }
export async function getBilling(accountId: string): Promise<BillingData>
// lib/domain/support.ts
export interface CaseSummary { id: string; caseNumber: string; subject: string | null; type: string | null; status: 'open' | 'closed'; createdAt: Date; orderNumber: string | null }
export async function getCases(accountId: string): Promise<CaseSummary[]>
export async function getCaseDetail(accountId: string, caseId: string): Promise<(CaseSummary & { description: string | null; resolution: string | null; updates: string | null; closedAt: Date | null }) | null>

// lib/serviceRequests/create.ts
export const REQUEST_TYPES = ['Swap Dumpster','Removal Request','Delivery ETA','Service Issue','Invoice Issue','Cancellation','Potty Service','Customer Inquiry'] as const
export type RequestType = typeof REQUEST_TYPES[number]
export interface CreateRequestInput { type: RequestType; description: string; orderId?: string; orderLineItemId?: string }
export async function createServiceRequest(accountId: string, userId: string, input: CreateRequestInput): Promise<{ id: string }>  // validates order belongs to account; pushStage='pending'
export interface ServiceRequestSummary { id: string; type: string; description: string; pushStage: string; sfCaseId: string | null; createdAt: Date; orderNumber: string | null }
export async function listServiceRequests(accountId: string): Promise<ServiceRequestSummary[]>
// lib/serviceRequests/push.ts
export const CUSTOMER_SUPPORT_RECORD_TYPE_ID = '0123o000001cpHZAAY'
export async function pushPendingServiceRequests(): Promise<{ pushed: number; failed: number }>  // ≤5 attempts w/ backoff; sets sfCaseId + pushStage='pushed'
```

## Prisma schema (authoritative — Task 2 creates exactly this)

```prisma
generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "rhel-openssl-3.0.x"]
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// ───── synced mirrors (PK = SF 18-char Id) ─────

model Account {
  id         String    @id
  name       String
  recordType String    @map("record_type")
  isDeleted  Boolean   @default(false) @map("is_deleted")
  sfModstamp DateTime  @map("sf_modstamp")
  contacts   Contact[]
  orders     Order[]
  @@map("accounts")
}

model Contact {
  id              String   @id
  accountId       String   @map("account_id")
  account         Account  @relation(fields: [accountId], references: [id])
  email           String?
  firstName       String?  @map("first_name")
  lastName        String?  @map("last_name")
  phone           String?
  isPersonAccount Boolean  @default(false) @map("is_person_account")
  isDeleted       Boolean  @default(false) @map("is_deleted")
  sfModstamp      DateTime @map("sf_modstamp")
  @@index([email])
  @@map("contacts")
}

model Order {
  id                  String          @id
  accountId           String          @map("account_id")
  account             Account         @relation(fields: [accountId], references: [id])
  orderNumber         String          @map("order_number")
  company             String?
  statusRaw           String?         @map("status_raw")
  customerStatus      String          @map("customer_status") // open | closed
  shippingStreet      String?         @map("shipping_street")
  shippingCity        String?         @map("shipping_city")
  shippingState       String?         @map("shipping_state")
  shippingZip         String?         @map("shipping_zip")
  siteAddress         String?         @map("site_address")
  storeName           String?         @map("store_name")
  storeNumber         String?         @map("store_number")
  locationNameOther   String?         @map("location_name_other")
  siteContact         String?         @map("site_contact")
  siteContactPhone    String?         @map("site_contact_phone")
  settingInstructions String?         @map("setting_instructions")
  deliveryDate        DateTime?       @map("delivery_date") @db.Date
  firstDeliveryDate   DateTime?       @map("first_delivery_date") @db.Date
  latestEndDate       DateTime?       @map("latest_end_date") @db.Date
  completedDate       DateTime?       @map("completed_date") @db.Date
  lineTotal           Decimal?        @map("line_total") @db.Decimal(12, 2)
  taxAmount           Decimal?        @map("tax_amount") @db.Decimal(12, 2)
  totalAfterTax       Decimal?        @map("total_after_tax") @db.Decimal(12, 2)
  chargedAmount       Decimal?        @map("charged_amount") @db.Decimal(12, 2)
  creditedAmount      Decimal?        @map("credited_amount") @db.Decimal(12, 2)
  balance             Decimal?        @db.Decimal(12, 2) // NewBalance__c
  customerPo          String?         @map("customer_po")
  customerRef         String?         @map("customer_ref")
  notesToCustomer     String?         @map("notes_to_customer")
  isDeleted           Boolean         @default(false) @map("is_deleted")
  sfModstamp          DateTime        @map("sf_modstamp")
  lineItems           OrderLineItem[]
  @@index([accountId, deliveryDate])
  @@map("orders")
}

model OrderLineItem {
  id                    String    @id
  orderId               String    @map("order_id")
  order                 Order     @relation(fields: [orderId], references: [id])
  recordType            String?   @map("record_type")
  isChargeAdjustment    Boolean   @default(false) @map("is_charge_adjustment")
  jobType               String?   @map("job_type")
  product               String?
  size                  String?
  quantity              Int?
  material              String?
  service               String?
  serviceDays           String?   @map("service_days")
  lifecycleRaw          String?   @map("lifecycle_raw")
  customerStatus        String    @map("customer_status") // UnitStatus
  deliveryConfirmed     Boolean   @default(false) @map("delivery_confirmed")
  deliveryConfirmedDate DateTime? @map("delivery_confirmed_date")
  removalConfirmed      Boolean   @default(false) @map("removal_confirmed")
  removalConfirmedDate  DateTime? @map("removal_confirmed_date")
  deliveryDate          DateTime? @map("delivery_date") @db.Date
  expectedDeliveryTime  String?   @map("expected_delivery_time")
  endDate               DateTime? @map("end_date") @db.Date
  projectedEndDate      DateTime? @map("projected_end_date") @db.Date
  swapDate              DateTime? @map("swap_date") @db.Date
  rentalDays            Int?      @map("rental_days")
  price                 Decimal?  @db.Decimal(12, 2)
  deliveryFee           Decimal?  @map("delivery_fee") @db.Decimal(12, 2)
  tonsIncluded          Decimal?  @map("tons_included") @db.Decimal(12, 2)
  pricePerTon           Decimal?  @map("price_per_ton") @db.Decimal(12, 2)
  pricePerAdditionalDay Decimal?  @map("price_per_additional_day") @db.Decimal(12, 2)
  additionalDays        Decimal?  @map("additional_days") @db.Decimal(12, 2)
  additionalTons        Decimal?  @map("additional_tons") @db.Decimal(12, 2)
  additionalFees        Decimal?  @map("additional_fees") @db.Decimal(12, 2)
  swapCharge            Decimal?  @map("swap_charge") @db.Decimal(12, 2)
  lineTotal             Decimal?  @map("line_total") @db.Decimal(12, 2)
  lineTax               Decimal?  @map("line_tax") @db.Decimal(12, 2)
  lineTotalAfterTax     Decimal?  @map("line_total_after_tax") @db.Decimal(12, 2)
  amountCharged         Decimal?  @map("amount_charged") @db.Decimal(12, 2)
  openBalance           Decimal?  @map("open_balance") @db.Decimal(12, 2)
  paymentReceived       Decimal?  @map("payment_received") @db.Decimal(12, 2)
  notesToCustomer       String?   @map("notes_to_customer")
  cancelReason          String?   @map("cancel_reason")
  isDeleted             Boolean   @default(false) @map("is_deleted")
  sfModstamp            DateTime  @map("sf_modstamp")
  @@index([orderId])
  @@map("order_line_items")
}

model CaseRecord {
  id              String    @id
  accountId       String?   @map("account_id")
  caseNumber      String    @map("case_number")
  subject         String?
  description     String?
  type            String?
  statusRaw       String?   @map("status_raw")
  status          String // open | closed
  priority        String?
  resolution      String?
  updates         String?
  origin          String?
  orderId         String?   @map("order_id")
  orderLineItemId String?   @map("order_line_item_id")
  sfCreatedAt     DateTime  @map("sf_created_at")
  closedAt        DateTime? @map("closed_at")
  isDeleted       Boolean   @default(false) @map("is_deleted")
  sfModstamp      DateTime  @map("sf_modstamp")
  @@index([accountId, sfCreatedAt])
  @@map("cases")
}

model Transaction {
  id                 String   @id
  name               String
  type               String?
  isVoided           Boolean  @default(false) @map("is_voided")
  countsTowardTotals Boolean  @default(true) @map("counts_toward_totals")
  amount             Decimal? @db.Decimal(12, 2)
  preTaxAmount       Decimal? @map("pre_tax_amount") @db.Decimal(12, 2)
  tax                Decimal? @db.Decimal(12, 2)
  creditAmount       Decimal? @map("credit_amount") @db.Decimal(12, 2)
  cardLast4          String?  @map("card_last4")
  cardType           String?  @map("card_type")
  itemDescription    String?  @map("item_description")
  paymentReference   String?  @map("payment_reference")
  orderId            String?  @map("order_id")
  accountId          String?  @map("account_id") // denormalized from Order__r.Account__c at sync
  orderLineItemId    String?  @map("order_line_item_id")
  sfCreatedAt        DateTime @map("sf_created_at")
  isDeleted          Boolean  @default(false) @map("is_deleted")
  sfModstamp         DateTime @map("sf_modstamp")
  @@index([accountId, sfCreatedAt])
  @@map("transactions")
}

model Invoice {
  id              String    @id
  name            String
  amountTotal     Decimal?  @map("amount_total") @db.Decimal(12, 2)
  amountPaid      Decimal?  @map("amount_paid") @db.Decimal(12, 2)
  amountDue       Decimal?  @map("amount_due") @db.Decimal(12, 2)
  taxAmount       Decimal?  @map("tax_amount") @db.Decimal(12, 2)
  transactionDate DateTime? @map("transaction_date") @db.Date
  dueDate         DateTime? @map("due_date") @db.Date
  status          String?
  type            String?
  paymentMethod   String?   @map("payment_method")
  memo            String?
  orderId         String?   @map("order_id")
  accountId       String?   @map("account_id") // denormalized from Order__r.Account__c
  isDeleted       Boolean   @default(false) @map("is_deleted")
  sfModstamp      DateTime  @map("sf_modstamp")
  @@index([accountId, transactionDate])
  @@map("invoices")
}

model DeliveryConfirmation {
  id              String    @id
  orderId         String?   @map("order_id")
  orderLineItemId String?   @map("order_line_item_id")
  isResponded     Boolean   @default(false) @map("is_responded")
  respondedOn     DateTime? @map("responded_on")
  response        String?
  isDeleted       Boolean   @default(false) @map("is_deleted")
  sfModstamp      DateTime  @map("sf_modstamp")
  @@index([orderId])
  @@map("delivery_confirmations")
}

model SavedCard {
  id            String   @id
  accountId     String   @map("account_id")
  company       String?
  label         String
  last4         String?
  cardType      String?  @map("card_type")
  expiryDisplay String?  @map("expiry_display")
  isPrimary     Boolean  @default(false) @map("is_primary")
  isDeleted     Boolean  @default(false) @map("is_deleted")
  sfModstamp    DateTime @map("sf_modstamp")
  @@index([accountId])
  @@map("saved_cards")
}

// ───── portal-native ─────

model PortalUser {
  id              String           @id @default(cuid())
  email           String           @unique
  createdAt       DateTime         @default(now()) @map("created_at")
  lastLoginAt     DateTime?        @map("last_login_at")
  sessions        Session[]
  serviceRequests ServiceRequest[]
  @@map("portal_users")
}

model Session {
  id        String     @id @default(cuid())
  tokenHash String     @unique @map("token_hash")
  userId    String     @map("user_id")
  user      PortalUser @relation(fields: [userId], references: [id])
  accountId String?    @map("account_id")
  createdAt DateTime   @default(now()) @map("created_at")
  expiresAt DateTime   @map("expires_at")
  @@index([userId])
  @@map("sessions")
}

model MagicLink {
  id        String    @id @default(cuid())
  email     String
  tokenHash String    @unique @map("token_hash")
  createdAt DateTime  @default(now()) @map("created_at")
  expiresAt DateTime  @map("expires_at")
  usedAt    DateTime? @map("used_at")
  requestIp String?   @map("request_ip")
  @@index([email, createdAt])
  @@map("magic_links")
}

model ServiceRequest {
  id              String     @id @default(cuid())
  userId          String     @map("user_id")
  user            PortalUser @relation(fields: [userId], references: [id])
  accountId       String     @map("account_id")
  orderId         String?    @map("order_id")
  orderLineItemId String?    @map("order_line_item_id")
  type            String
  description     String
  pushStage       String     @default("pending") @map("push_stage") // pending | pushed | failed
  sfCaseId        String?    @map("sf_case_id")
  attempts        Int        @default(0)
  lastError       String?    @map("last_error")
  createdAt       DateTime   @default(now()) @map("created_at")
  updatedAt       DateTime   @updatedAt @map("updated_at")
  @@index([pushStage])
  @@index([accountId, createdAt])
  @@map("service_requests")
}

model SyncState {
  object        String   @id
  highWaterMark DateTime @map("high_water_mark")
  updatedAt     DateTime @updatedAt @map("updated_at")
  @@map("sync_state")
}

model SyncRun {
  id             String    @id @default(cuid())
  startedAt      DateTime  @default(now()) @map("started_at")
  finishedAt     DateTime? @map("finished_at")
  status         String // running | success | failed
  objectsSummary Json?     @map("objects_summary")
  error          String?
  @@index([status, finishedAt])
  @@map("sync_runs")
}

model AuditLog {
  id        String   @id @default(cuid())
  userId    String?  @map("user_id")
  accountId String?  @map("account_id")
  action    String
  detail    Json?
  createdAt DateTime @default(now()) @map("created_at")
  @@index([userId, createdAt])
  @@map("audit_log")
}
```

---

# Tasks

### Task 1: Repo scaffold

**Files:**

- Create: `package.json`
- Create: `tsconfig.json`
- Create: `next-env.d.ts`
- Create: `next.config.mjs`
- Create: `tailwind.config.ts`
- Create: `postcss.config.mjs`
- Create: `vitest.config.ts`
- Create: `.env.example`
- Create: `.gitignore`
- Create: `app/layout.tsx`
- Create: `app/globals.css`
- Create: `app/page.tsx`
- Create: `README.md`
- Test: `tests/unit/smoke.test.ts`

**Interfaces:**

- Consumes: nothing — this is the first task. All work happens in `/Users/ldr/streamlink` (the repo root; the `docs/` directory already exists and is committed along with the scaffold).
- Produces (every later task relies on these):
  - npm scripts: `npm run dev`, `npm run build`, `npm run start`, `npm run typecheck` (tsc --noEmit), `npm test` (vitest run). Later tasks add `db:up` / `db:migrate` / `db:generate` (Task 2) and seed/e2e scripts (later tasks) — do NOT add those here.
  - The `@/*` import alias resolving to the repo root, working in both `tsc` (tsconfig `paths`) and vitest (`resolve.alias`), e.g. `import { prisma } from '@/lib/db'`.
  - vitest discovers `tests/unit/**/*.test.ts` and `tests/integration/**/*.test.ts`, node environment, `passWithNoTests: true`.
  - `.env.example` — the canonical non-secret config template (Task 2 copies it to `.env`).
  - All npm dependencies installed for the whole project (no later task runs `npm install` for new packages): next, react, react-dom, @prisma/client, zod, pino, @aws-sdk/client-ses, @aws-sdk/client-secrets-manager, decimal.js; dev: prisma, typescript, @types/node, @types/react, @types/react-dom, tailwindcss, postcss, autoprefixer, vitest, tsx, esbuild, pino-pretty, @playwright/test.
  - App shell: `app/layout.tsx` (root layout importing Tailwind globals) and `app/page.tsx` (`/` → `redirect('/dashboard')` — the dashboard route itself arrives in a later task; the redirect target 404s until then, which is expected).

**Steps:**

- [ ] Confirm prerequisites. Run `node --version` (expect `v20.x`; if not, install Node 20 via nvm before continuing) and `docker --version` (needed from Task 2 onward).

- [ ] Initialize the git repo:

  ```bash
  cd /Users/ldr/streamlink && git init -b main
  ```

- [ ] Create `.gitignore` FIRST (so `node_modules` is never staged):

  ```gitignore
  node_modules/
  .next/
  dist/
  test-results/

  # env files are local-only; the template is the one exception
  .env*
  !.env.example
  ```

- [ ] Create `package.json` (hand-written — we deliberately do not use create-next-app). Note: only `dev`/`build`/`start`/`typecheck`/`test` scripts exist at this point; `db:*`, seed, and e2e scripts are added by the tasks that need them.

  ```json
  {
    "name": "streamlink",
    "version": "0.1.0",
    "private": true,
    "engines": {
      "node": ">=20"
    },
    "scripts": {
      "dev": "next dev",
      "build": "next build",
      "start": "next start",
      "typecheck": "tsc --noEmit",
      "test": "vitest run"
    },
    "dependencies": {
      "@aws-sdk/client-secrets-manager": "^3.620.0",
      "@aws-sdk/client-ses": "^3.620.0",
      "@prisma/client": "^5.19.0",
      "decimal.js": "^10.4.3",
      "next": "^14.2.5",
      "pino": "^9.3.2",
      "react": "^18.3.1",
      "react-dom": "^18.3.1",
      "zod": "^3.23.8"
    },
    "devDependencies": {
      "@playwright/test": "^1.46.0",
      "@types/node": "^20.14.15",
      "@types/react": "^18.3.3",
      "@types/react-dom": "^18.3.0",
      "autoprefixer": "^10.4.20",
      "esbuild": "^0.23.0",
      "pino-pretty": "^11.2.2",
      "postcss": "^8.4.41",
      "prisma": "^5.19.0",
      "tailwindcss": "^3.4.10",
      "tsx": "^4.17.0",
      "typescript": "^5.5.4",
      "vitest": "^2.0.5"
    }
  }
  ```

- [ ] Install dependencies:

  ```bash
  npm install
  ```

  Expected: exits 0, creates `node_modules/` and `package-lock.json`. (The `@prisma/client` postinstall prints a warning that no Prisma schema was found — that is fine; Task 2 creates the schema.)

- [ ] Create `tsconfig.json` (strict, bundler resolution, `@/*` alias to repo root):

  ```json
  {
    "compilerOptions": {
      "target": "ES2022",
      "lib": ["dom", "dom.iterable", "esnext"],
      "allowJs": false,
      "skipLibCheck": true,
      "strict": true,
      "noEmit": true,
      "esModuleInterop": true,
      "module": "esnext",
      "moduleResolution": "bundler",
      "resolveJsonModule": true,
      "isolatedModules": true,
      "jsx": "preserve",
      "incremental": true,
      "plugins": [{ "name": "next" }],
      "paths": {
        "@/*": ["./*"]
      }
    },
    "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
    "exclude": ["node_modules"]
  }
  ```

- [ ] Create `next-env.d.ts` by hand (normally `next dev`/`next build` generates it, but `npm run typecheck` needs the ambient Next types — e.g. the `*.css` module declaration used by `app/layout.tsx` — before the first build):

  ```ts
  /// <reference types="next" />
  /// <reference types="next/image-types/global" />

  // NOTE: This file should not be edited
  // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
  ```

- [ ] Create `next.config.mjs`:

  ```js
  /**
   * IMPORTANT: there is deliberately NO `env: {}` block in this config.
   * Secrets are resolved at RUNTIME via lib/secrets.ts (getSecret) — never
   * inlined into the build bundle. This is an intentional break from the
   * checkout site's next.config env-inlining pattern, which bakes credentials
   * into build artifacts and forces a rebuild to rotate them. Do not add env{}.
   */
  /** @type {import('next').NextConfig} */
  const nextConfig = {
    reactStrictMode: true,
  };

  export default nextConfig;
  ```

- [ ] Create `tailwind.config.ts`:

  ```ts
  import type { Config } from 'tailwindcss';

  const config: Config = {
    content: ['./app/**/*.{ts,tsx}', './lib/**/*.{ts,tsx}'],
    theme: {
      extend: {},
    },
    plugins: [],
  };

  export default config;
  ```

- [ ] Create `postcss.config.mjs`:

  ```js
  /** @type {import('postcss-load-config').Config} */
  const config = {
    plugins: {
      tailwindcss: {},
      autoprefixer: {},
    },
  };

  export default config;
  ```

- [ ] Create `vitest.config.ts` (node environment, unit + integration includes, `passWithNoTests`, and the same `@` alias as tsconfig). Note `fileParallelism: false`: integration suites truncate shared tables and assert global row counts, so test files must run sequentially — this keeps `npm test` deterministic from day one:

  ```ts
  import path from 'node:path';
  import { fileURLToPath } from 'node:url';
  import { defineConfig } from 'vitest/config';

  const rootDir = path.dirname(fileURLToPath(import.meta.url));

  export default defineConfig({
    test: {
      environment: 'node',
      include: ['tests/unit/**/*.test.ts', 'tests/integration/**/*.test.ts'],
      passWithNoTests: true,
      fileParallelism: false, // integration tests share one local database — run files sequentially
    },
    resolve: {
      alias: {
        '@': rootDir,
      },
    },
  });
  ```

- [ ] Verify the test runner wiring before any test exists:

  ```bash
  npm test
  ```

  Expected: vitest prints `No test files found, exiting with code 0` (thanks to `passWithNoTests: true`) and the command exits 0.

- [ ] Write the smoke test `tests/unit/smoke.test.ts` (scaffold exception to strict TDD: there is no domain logic yet — this test exists to prove the vitest + TypeScript toolchain executes):

  ```ts
  import { describe, expect, it } from 'vitest';

  describe('toolchain smoke test', () => {
    it('runs TypeScript tests under vitest', () => {
      expect(1 + 1).toBe(2);
    });
  });
  ```

- [ ] Run the smoke test:

  ```bash
  npm test
  ```

  Expected: `Test Files  1 passed (1)` / `Tests  1 passed (1)`, exit 0.

- [ ] Create `app/globals.css`:

  ```css
  @tailwind base;
  @tailwind components;
  @tailwind utilities;
  ```

- [ ] Create `app/layout.tsx`:

  ```tsx
  import type { Metadata } from 'next';
  import type { ReactNode } from 'react';
  import './globals.css';

  export const metadata: Metadata = {
    title: 'Streamlink',
    description: 'LDR Site Services customer portal',
  };

  export default function RootLayout({ children }: { children: ReactNode }) {
    return (
      <html lang="en">
        <body className="min-h-screen bg-white text-gray-900 antialiased">{children}</body>
      </html>
    );
  }
  ```

- [ ] Create `app/page.tsx` (`/` always forwards to the dashboard; the login redirect for anonymous users is handled later by session middleware/`requireSession`, not here):

  ```tsx
  import { redirect } from 'next/navigation';

  export default function Home(): never {
    redirect('/dashboard');
  }
  ```

- [ ] Create `.env.example` (non-secret local defaults; secret values are intentionally blank — in deployed environments they come from AWS Secrets Manager via `getSecret()`, Task 3):

  ```bash
  DATABASE_URL=postgresql://streamlink:streamlink@localhost:5433/streamlink
  SF_MODE=mock
  SF_INSTANCE_URL=
  SF_CLIENT_ID=
  SF_CLIENT_SECRET=
  EMAIL_MODE=log
  SES_FROM_ADDRESS=
  APP_BASE_URL=http://localhost:3000
  AWS_REGION=us-east-1
  ```

- [ ] Create `README.md`:

  ````markdown
  # Streamlink

  Customer portal for LDR Site Services: a Next.js app with its own Postgres
  read model synced from Salesforce, magic-link auth, account-scoped
  dashboards (sites/orders, billing, support), and one write path
  (service requests → Salesforce Case).

  ## Development

  ```bash
  npm install
  cp .env.example .env    # local non-secret defaults; mock mode needs no credentials
  npm run dev
  ```

  - `npm run typecheck` — TypeScript strict check
  - `npm test` — vitest (unit + integration; integration tests need the local DB — see Task 2's `npm run db:up`)
  - `npm run build` — production build

  Spec: `docs/superpowers/specs/2026-07-16-streamlink-v2-design.md`
  Data reference: `docs/design-input-brief.md`
  ````

- [ ] Run the full verification suite:

  ```bash
  npm run typecheck
  npm test
  npm run build
  ```

  Expected: `typecheck` exits 0 with no output; `npm test` shows `1 passed`; `npm run build` prints `✓ Compiled successfully` and a route table containing `○ /` (it may print a note that ESLint is not configured — that is fine, linting is not part of this scaffold).

- [ ] Commit everything (including `package-lock.json`, `next-env.d.ts`, and the pre-existing `docs/` directory):

  ```bash
  git add -A && git commit -m "chore: scaffold streamlink Next.js app (strict TS, Tailwind, vitest)"
  ```

---

### Task 2: Prisma schema + local database

**Files:**

- Create: `docker-compose.yml`
- Create: `prisma/schema.prisma`
- Create: `.env` (local copy of `.env.example`; gitignored, never committed)
- Create: `prisma/migrations/<timestamp>_init/migration.sql` (generated by `prisma migrate dev`; committed)
- Modify: `package.json` (add `db:up`, `db:migrate`, `db:generate` scripts)
- Test: `tests/integration/db.test.ts`

**Interfaces:**

- Consumes (from Task 1): npm toolchain (`npm test`, vitest integration include path), `.env.example`, installed `prisma` + `@prisma/client` packages.
- Produces (later tasks rely on these):
  - The 16 Prisma models from the authoritative schema — `Account`, `Contact`, `Order`, `OrderLineItem`, `CaseRecord`, `Transaction`, `Invoice`, `DeliveryConfirmation`, `SavedCard`, `PortalUser`, `Session`, `MagicLink`, `ServiceRequest`, `SyncState`, `SyncRun`, `AuditLog` — mapped to snake_case tables.
  - The generated `@prisma/client` types, including `Prisma.AccountUncheckedCreateInput` etc. that the `lib/sync/mappers.ts` contract signatures reference in later tasks.
  - npm scripts: `npm run db:up` (start local Postgres, wait until healthy), `npm run db:migrate` (`prisma migrate dev`), `npm run db:generate` (`prisma generate`).
  - The local integration-test database convention: Postgres 16 in Docker on port **5433**, user/password/database all `streamlink`. Every later integration test assumes `npm run db:up` has been run and migrations applied. Unit tests must never need it.

**Steps:**

- [ ] Create `docker-compose.yml`:

  ```yaml
  services:
    db:
      image: postgres:16
      container_name: streamlink-db
      environment:
        POSTGRES_USER: streamlink
        POSTGRES_PASSWORD: streamlink
        POSTGRES_DB: streamlink
      ports:
        - '5433:5432'
      volumes:
        - streamlink_pgdata:/var/lib/postgresql/data
      healthcheck:
        test: ['CMD-SHELL', 'pg_isready -U streamlink -d streamlink']
        interval: 2s
        timeout: 5s
        retries: 15

  volumes:
    streamlink_pgdata:
  ```

- [ ] Add the three database scripts to `package.json`. Replace the existing `"scripts"` object with exactly:

  ```json
    "scripts": {
      "dev": "next dev",
      "build": "next build",
      "start": "next start",
      "typecheck": "tsc --noEmit",
      "test": "vitest run",
      "db:up": "docker compose up -d --wait db",
      "db:migrate": "prisma migrate dev",
      "db:generate": "prisma generate"
    },
  ```

- [ ] Create the local env file (Prisma CLI and Prisma Client both auto-load `.env` from the repo root for `DATABASE_URL`):

  ```bash
  cp .env.example .env
  ```

- [ ] Start the database:

  ```bash
  npm run db:up
  ```

  Expected: exits 0 after the container reports healthy (`--wait` blocks on the healthcheck). Verify with `docker compose ps` → the `db` service shows `Up (healthy)` with `0.0.0.0:5433->5432/tcp`.

- [ ] Write the failing integration test `tests/integration/db.test.ts`:

  ```ts
  // Integration test: requires the local Postgres from `npm run db:up` with
  // migrations applied (`npm run db:migrate`). Unit tests must NOT touch the DB;
  // this file lives under tests/integration/ for that reason.
  import { Prisma, PrismaClient } from '@prisma/client';
  import { afterAll, beforeAll, describe, expect, it } from 'vitest';

  process.env.DATABASE_URL ??= 'postgresql://streamlink:streamlink@localhost:5433/streamlink';

  const prisma = new PrismaClient();
  const EMAIL = 'db-test@example.com';

  describe('portal database', () => {
    beforeAll(async () => {
      await prisma.portalUser.deleteMany({ where: { email: EMAIL } });
    });

    afterAll(async () => {
      await prisma.portalUser.deleteMany({ where: { email: EMAIL } });
      await prisma.$disconnect();
    });

    it('creates and reads back a PortalUser', async () => {
      const created = await prisma.portalUser.create({ data: { email: EMAIL } });
      expect(created.id).toBeTruthy();

      const found = await prisma.portalUser.findUnique({ where: { email: EMAIL } });
      expect(found?.id).toBe(created.id);
      expect(found?.email).toBe(EMAIL);
      expect(found?.createdAt).toBeInstanceOf(Date);
      expect(found?.lastLoginAt).toBeNull();
    });

    it('rejects a duplicate email via the unique constraint', async () => {
      await expect(
        prisma.portalUser.create({ data: { email: EMAIL } }),
      ).rejects.toMatchObject({
        code: 'P2002',
        name: 'PrismaClientKnownRequestError',
      } satisfies Partial<Prisma.PrismaClientKnownRequestError>);
    });
  });
  ```

- [ ] Run it and confirm it fails for the right reason:

  ```bash
  npx vitest run tests/integration/db.test.ts
  ```

  Expected FAILURE: the test file errors at import time with `@prisma/client did not initialize yet. Please run "prisma generate" and try to import it again.` — the client has never been generated because no schema exists yet.

- [ ] Create `prisma/schema.prisma` by **copying the authoritative schema verbatim**. Open this plan document (`docs/superpowers/plans/2026-07-16-streamlink-v2.md`), find the section titled **"Prisma schema (authoritative — Task 2 creates exactly this)"**, and copy the entire fenced ```prisma``` block — from `generator client {` through the closing `}` of `model AuditLog` — into `prisma/schema.prisma`. Do NOT retype it from memory, do NOT reorder, reformat, rename, or "fix" anything. That section is the single source of truth; this task section intentionally does not duplicate it to avoid transcription drift.

- [ ] Verify the copy:

  ```bash
  grep -c '^model ' prisma/schema.prisma   # expected output: 16
  grep -c '@@map'  prisma/schema.prisma    # expected output: 16 (one table mapping per model)
  npx prisma validate                       # expected: "The schema at prisma/schema.prisma is valid"
  ```

- [ ] Create and apply the initial migration (this also runs `prisma generate`):

  ```bash
  npm run db:migrate -- --name init
  ```

  Expected: prints the new migration directory `prisma/migrations/<timestamp>_init/`, `Your database is now in sync with your schema.`, and `Generated Prisma Client`. (The schema's `binaryTargets` also downloads the `rhel-openssl-3.0.x` engine used by Lambda/Amplify — a local no-op otherwise.)

- [ ] Re-run the integration test:

  ```bash
  npx vitest run tests/integration/db.test.ts
  ```

  Expected: `Test Files  1 passed (1)` / `Tests  2 passed (2)`.

- [ ] Run the full suite and typecheck:

  ```bash
  npm test
  npm run typecheck
  ```

  Expected: 2 test files pass (smoke + db, 3 tests total); typecheck exits 0.

- [ ] Commit (the migration directory IS committed; `.env` must NOT appear in `git status` — it is gitignored):

  ```bash
  git add -A && git commit -m "feat: add Prisma schema, docker-compose Postgres, and init migration"
  ```

---

### Task 3: lib foundation — logger, Prisma singleton, runtime secrets

**Files:**

- Create: `lib/logger.ts`
- Create: `lib/db.ts`
- Create: `lib/secrets.ts`
- Test: `tests/unit/logger.test.ts`
- Test: `tests/unit/db.test.ts`
- Test: `tests/unit/secrets.test.ts`

**Interfaces:**

- Consumes: generated `@prisma/client` (Task 2); `pino`, `pino-pretty`, `@aws-sdk/client-secrets-manager` (installed in Task 1); the `@/*` alias.
- Produces (contract-exact — every later task imports these):

  ```ts
  // lib/logger.ts
  export const logger: pino.Logger

  // lib/db.ts
  export const prisma: PrismaClient        // singleton, globalThis-cached outside production

  // lib/secrets.ts
  export interface SecretsClient { send(command: GetSecretValueCommand): Promise<{ SecretString?: string }> }
  export function _setSecretsClientForTests(client: SecretsClient | null): void   // null restores the real client; always clears the cache
  export async function getSecret(name: string): Promise<string>                  // env var first; AWS Secrets Manager fallback; throws if absent
  ```

  Reminder from Global Constraints: after this task, **no file outside `lib/secrets.ts` may read credentials from `process.env`** — credentials go through `getSecret()`. Non-secret config (`SF_MODE`, `EMAIL_MODE`, `APP_BASE_URL`, `DATABASE_URL`, `LOG_LEVEL`, `NODE_ENV`) may still be read from `process.env` directly.

**Steps:**

- [ ] Write the failing test `tests/unit/logger.test.ts`:

  ```ts
  import { afterEach, describe, expect, it, vi } from 'vitest';

  describe('logger', () => {
    afterEach(() => {
      vi.unstubAllEnvs();
      vi.resetModules();
    });

    it('defaults to level info when LOG_LEVEL is unset or empty', async () => {
      vi.stubEnv('LOG_LEVEL', '');
      vi.resetModules();
      const { logger } = await import('@/lib/logger');
      expect(logger.level).toBe('info');
      expect(typeof logger.info).toBe('function');
      expect(typeof logger.error).toBe('function');
    });

    it('honors the LOG_LEVEL env var', async () => {
      vi.stubEnv('LOG_LEVEL', 'debug');
      vi.resetModules();
      const { logger } = await import('@/lib/logger');
      expect(logger.level).toBe('debug');
    });
  });
  ```

- [ ] Write the failing test `tests/unit/db.test.ts` (no DB connection is made — PrismaClient connects lazily on first query, so this stays a unit test):

  ```ts
  import { describe, expect, it } from 'vitest';
  import { prisma } from '@/lib/db';

  describe('prisma singleton', () => {
    it('exposes one PrismaClient, cached on globalThis outside production', async () => {
      const again = await import('@/lib/db');
      expect(again.prisma).toBe(prisma);
      expect(typeof prisma.$queryRaw).toBe('function');
      // NODE_ENV under vitest is 'test' (not production), so the instance must
      // be cached on globalThis — that is the Next.js dev hot-reload guard.
      expect((globalThis as { prisma?: unknown }).prisma).toBe(prisma);
    });
  });
  ```

- [ ] Write the failing test `tests/unit/secrets.test.ts`:

  ```ts
  import { GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
  import { afterEach, describe, expect, it, vi } from 'vitest';
  import { _setSecretsClientForTests, getSecret, type SecretsClient } from '@/lib/secrets';

  interface FakeSecretsClient {
    client: SecretsClient;
    requestedSecretIds: string[];
  }

  function makeFakeClient(
    respond: (secretId: string) => Promise<{ SecretString?: string }>,
  ): FakeSecretsClient {
    const requestedSecretIds: string[] = [];
    const client: SecretsClient = {
      send(command: GetSecretValueCommand): Promise<{ SecretString?: string }> {
        const secretId = command.input.SecretId ?? '';
        requestedSecretIds.push(secretId);
        return respond(secretId);
      },
    };
    return { client, requestedSecretIds };
  }

  describe('getSecret', () => {
    afterEach(() => {
      vi.unstubAllEnvs();
      _setSecretsClientForTests(null); // restore real client, clear cache
    });

    it('returns the process.env value when set, without calling Secrets Manager', async () => {
      vi.stubEnv('TEST_SECRET_ENV_HIT', 'value-from-env');
      const fake = makeFakeClient(() => Promise.reject(new Error('must not be called')));
      _setSecretsClientForTests(fake.client);

      await expect(getSecret('TEST_SECRET_ENV_HIT')).resolves.toBe('value-from-env');
      expect(fake.requestedSecretIds).toEqual([]);
    });

    it('falls back to Secrets Manager with the default streamlink/ prefix', async () => {
      const fake = makeFakeClient(() => Promise.resolve({ SecretString: 'value-from-sm' }));
      _setSecretsClientForTests(fake.client);

      await expect(getSecret('TEST_SECRET_SM_FALLBACK')).resolves.toBe('value-from-sm');
      expect(fake.requestedSecretIds).toEqual(['streamlink/TEST_SECRET_SM_FALLBACK']);
    });

    it('honors a SECRETS_PREFIX env override', async () => {
      vi.stubEnv('SECRETS_PREFIX', 'custom-prefix/');
      const fake = makeFakeClient(() => Promise.resolve({ SecretString: 'prefixed' }));
      _setSecretsClientForTests(fake.client);

      await expect(getSecret('TEST_SECRET_PREFIXED')).resolves.toBe('prefixed');
      expect(fake.requestedSecretIds).toEqual(['custom-prefix/TEST_SECRET_PREFIXED']);
    });

    it('caches Secrets Manager results — the client is called once per name', async () => {
      const fake = makeFakeClient(() => Promise.resolve({ SecretString: 'cached-value' }));
      _setSecretsClientForTests(fake.client);

      await expect(getSecret('TEST_SECRET_CACHED')).resolves.toBe('cached-value');
      await expect(getSecret('TEST_SECRET_CACHED')).resolves.toBe('cached-value');
      expect(fake.requestedSecretIds).toEqual(['streamlink/TEST_SECRET_CACHED']);
    });

    it('throws "Secret not found: <name>" when neither source has it', async () => {
      const fake = makeFakeClient(() => Promise.reject(new Error('ResourceNotFoundException')));
      _setSecretsClientForTests(fake.client);

      await expect(getSecret('TEST_SECRET_MISSING')).rejects.toThrow(
        'Secret not found: TEST_SECRET_MISSING',
      );
    });
  });
  ```

- [ ] Run the three new test files and confirm they fail for the right reason:

  ```bash
  npx vitest run tests/unit/logger.test.ts tests/unit/db.test.ts tests/unit/secrets.test.ts
  ```

  Expected FAILURE: all three files error at collection with `Failed to resolve import "@/lib/logger" ...` (and `@/lib/db`, `@/lib/secrets`) — the modules do not exist yet.

- [ ] Create `lib/logger.ts`:

  ```ts
  import pino from 'pino';

  /**
   * Structured logger (checkout convention: pino JSON → CloudWatch).
   * - Level from LOG_LEVEL (non-secret config), defaulting to 'info';
   *   `||` deliberately treats an empty string as unset.
   * - pino-pretty is a dev-only affordance: enabled ONLY when
   *   NODE_ENV=development so production/Lambda/test output stays raw JSON.
   */
  export const logger: pino.Logger = pino({
    level: process.env.LOG_LEVEL || 'info',
    ...(process.env.NODE_ENV === 'development'
      ? { transport: { target: 'pino-pretty', options: { colorize: true } } }
      : {}),
  });
  ```

- [ ] Create `lib/db.ts`:

  ```ts
  import { PrismaClient } from '@prisma/client';

  /**
   * PrismaClient singleton. Next.js dev mode hot-reloads modules, which would
   * otherwise construct a new PrismaClient (and connection pool) per reload and
   * exhaust Postgres connections — so outside production the instance is cached
   * on globalThis and reused across reloads.
   */
  const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

  export const prisma: PrismaClient = globalForPrisma.prisma ?? new PrismaClient();

  if (process.env.NODE_ENV !== 'production') {
    globalForPrisma.prisma = prisma;
  }
  ```

- [ ] Create `lib/secrets.ts`:

  ```ts
  import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager';

  /**
   * Runtime secrets access. This module is the ONLY place credentials may be
   * read from process.env (Global Constraints). Everything else calls
   * getSecret('SF_CLIENT_SECRET') etc.
   *
   * Resolution order:
   *   1. process.env[name]           — local dev / tests (empty string = unset,
   *                                    because .env.example ships blank values)
   *   2. in-process cache            — so Secrets Manager is hit once per name
   *   3. AWS Secrets Manager         — SecretId = SECRETS_PREFIX + name
   *                                    (SECRETS_PREFIX defaults to 'streamlink/')
   * Throws `Secret not found: <name>` if all sources miss. Secrets are never
   * inlined at build time (see next.config.mjs).
   */

  /** The subset of SecretsManagerClient that getSecret() uses — injectable for tests. */
  export interface SecretsClient {
    send(command: GetSecretValueCommand): Promise<{ SecretString?: string }>;
  }

  const cache = new Map<string, string>();
  let client: SecretsClient | null = null;

  function getClient(): SecretsClient {
    if (client === null) {
      // Region resolves via the standard AWS chain (AWS_REGION env, profile, IAM role).
      client = new SecretsManagerClient({});
    }
    return client;
  }

  /**
   * Test seam: inject a fake Secrets Manager client. Passing null restores the
   * default (lazily constructed) real client. Always clears the cache so tests
   * stay independent.
   */
  export function _setSecretsClientForTests(testClient: SecretsClient | null): void {
    client = testClient;
    cache.clear();
  }

  export async function getSecret(name: string): Promise<string> {
    const fromEnv = process.env[name];
    if (fromEnv !== undefined && fromEnv !== '') {
      return fromEnv;
    }

    const cached = cache.get(name);
    if (cached !== undefined) {
      return cached;
    }

    const prefix = process.env.SECRETS_PREFIX || 'streamlink/';
    let secretString: string | undefined;
    try {
      const result = await getClient().send(
        new GetSecretValueCommand({ SecretId: prefix + name }),
      );
      secretString = result.SecretString;
    } catch {
      // Treat any Secrets Manager failure (missing secret, no permissions,
      // no network in local dev) as "not found" — callers get one uniform,
      // contract-specified error.
      secretString = undefined;
    }

    if (secretString !== undefined && secretString !== '') {
      cache.set(name, secretString);
      return secretString;
    }

    throw new Error(`Secret not found: ${name}`);
  }
  ```

- [ ] Re-run the three test files:

  ```bash
  npx vitest run tests/unit/logger.test.ts tests/unit/db.test.ts tests/unit/secrets.test.ts
  ```

  Expected: `Test Files  3 passed (3)` / `Tests  8 passed (8)`.

- [ ] Run the full verification suite (DB must be up from Task 2 for the integration file):

  ```bash
  npm run typecheck
  npm test
  ```

  Expected: typecheck exits 0; `npm test` shows `Test Files  5 passed (5)` / `Tests  11 passed (11)` (smoke 1, db integration 2, logger 2, db unit 1, secrets 5).

- [ ] Commit:

  ```bash
  git add -A && git commit -m "feat: add logger, prisma singleton, and runtime secrets foundation"
  ```
### Task 4: SOQL safety + field allowlist (security core)

**Files:**
- Create: `lib/salesforce/soql.ts`
- Create: `lib/sync/denylist.ts`
- Create: `lib/sync/fieldAllowlist.ts`
- Test: `tests/unit/soql.test.ts`
- Test: `tests/unit/allowlist-leak.test.ts`

**Interfaces:**
- Consumes: only the Task 1 scaffold (strict `tsconfig.json`, vitest config, `npm test`). No database, no other lib code.
- Produces (later tasks rely on these exact signatures):
  - `soql(strings: TemplateStringsArray, ...values: (string | number | Date | string[])[]): string` — the ONLY permitted way to build SOQL anywhere in the repo (used by the sync worker, backfill, and service-request push tasks).
  - `SYNC_OBJECTS`, `type SyncObject`, `ALLOWLIST: Record<SyncObject, readonly string[]>`, `selectClause(obj: SyncObject): string`, `whereClause(obj: SyncObject): string` from `lib/sync/fieldAllowlist.ts` — the sync tasks generate every SELECT from these.
  - `DENYLIST: readonly string[]` from `lib/sync/denylist.ts` — consumed only by the leak test; this module is the single file in the repo where sensitive field names are allowed to appear (Global Constraints).

**Notes (design decisions the engineer must not "fix"):**
- `Expiration_Date__c` is denylisted on `Transaction__c` in the brief (§6.2) but is deliberately **absent from `DENYLIST`**: the identical API name is a legitimately exposed display field on `Credit_Card__c` (brief §1.2), and the leak test compares bare field names across all objects. `Transaction__c.Expiration_Date__c` is protected structurally instead — it is simply not in `ALLOWLIST['Transaction__c']`, and every sync SELECT is generated from the allowlist, so un-listed fields never leave Salesforce. The same reasoning applies to `Tax_Amount__c` (deprecated on `Customer_Orders__c`, but the real tax field on `Netsuite_Transactions__c`).
- Wildcard families in the brief (`Vendor_Additional_*`, "all `Netsuite_*` vendor/PO fields", `QB_*` plumbing) cannot be enumerated exhaustively. Every concretely named member goes in `DENYLIST`; in addition the leak test rejects any allowlist entry matching the family regexes (`/^Vendor_/i`, `/^Netsuite_/i`, `/^QB_/i`, `/Margin/i`, `/_Token__c$/i`), so an allowlisted `Vendor_Anything__c` fails CI even if nobody added it to the denylist.
- The brief's `Service_Day__c..Service_Day_4__c` expands to `Service_Day__c`, `Service_Day_2__c`, `Service_Day_3__c`, `Service_Day_4__c`.
- `Line_Price__c` from the brief's OLI pricing list is omitted from the allowlist: the authoritative Prisma schema has no column for it (`LIne_Total_Amount__c` → `line_total` is the stored line amount).
- The Case record-type filter inlines the literal `'0123o000001cpHZAAY'` — `CUSTOMER_SUPPORT_RECORD_TYPE_ID` lives in `lib/serviceRequests/push.ts`, which does not exist yet; do not import it here.

**Steps:**

- [ ] Write the failing SOQL test. Create `tests/unit/soql.test.ts`:

```ts
import { describe, expect, it } from 'vitest';
import { soql } from '../../lib/salesforce/soql';

describe('soql tagged template', () => {
  it('single-quotes plain string values', () => {
    expect(soql`SELECT Id FROM Account WHERE Name = ${'Target Corp'}`).toBe(
      "SELECT Id FROM Account WHERE Name = 'Target Corp'",
    );
  });

  it('escapes single quotes inside string values', () => {
    expect(soql`WHERE Name = ${"O'Brien"}`).toBe("WHERE Name = 'O\\'Brien'");
  });

  it('escapes backslashes before quotes (no double-unescape)', () => {
    // input is the four characters:  a  \  '  b
    expect(soql`WHERE Name = ${"a\\'b"}`).toBe("WHERE Name = 'a\\\\\\'b'");
  });

  it('neutralises a SOQL injection attempt', () => {
    const malicious = "x' OR Name!='";
    const query = soql`SELECT Id FROM Contact WHERE Email = ${malicious}`;
    // The payload stays inside one string literal; the embedded quote and the
    // trailing quote are both escaped, so no operator escapes the literal.
    expect(query).toBe("SELECT Id FROM Contact WHERE Email = 'x\\' OR Name!=\\''");
  });

  it('inlines numbers without quotes', () => {
    expect(soql`WHERE Quantity__c >= ${3}`).toBe('WHERE Quantity__c >= 3');
    expect(soql`WHERE Price__c > ${19.5}`).toBe('WHERE Price__c > 19.5');
  });

  it('rejects non-finite numbers', () => {
    expect(() => soql`WHERE Quantity__c > ${Number.NaN}`).toThrow(/non-finite/);
    expect(() => soql`WHERE Quantity__c > ${Number.POSITIVE_INFINITY}`).toThrow(/non-finite/);
  });

  it('formats Dates as unquoted SOQL datetime literals', () => {
    const d = new Date('2026-07-16T10:30:00.000Z');
    expect(soql`WHERE SystemModstamp > ${d}`).toBe(
      'WHERE SystemModstamp > 2026-07-16T10:30:00.000Z',
    );
  });

  it('formats string arrays as quoted IN lists with each element escaped', () => {
    expect(soql`WHERE Type IN ${['Payments', "Cr'edit"]}`).toBe(
      "WHERE Type IN ('Payments','Cr\\'edit')",
    );
  });

  it('rejects empty arrays (IN () is invalid SOQL)', () => {
    expect(() => soql`WHERE Type IN ${[] as string[]}`).toThrow(/empty array/);
  });

  it('composes multiple interpolations', () => {
    const since = new Date('2026-01-01T00:00:00.000Z');
    const query = soql`SELECT Id FROM Case WHERE AccountId = ${'001xx0000000001AAA'} AND SystemModstamp > ${since} LIMIT ${10}`;
    expect(query).toBe(
      "SELECT Id FROM Case WHERE AccountId = '001xx0000000001AAA' AND SystemModstamp > 2026-01-01T00:00:00.000Z LIMIT 10",
    );
  });
});
```

- [ ] Run `npx vitest run tests/unit/soql.test.ts` — expected FAILURE: module-resolution error (`Failed to resolve import "../../lib/salesforce/soql"`), 0 tests executed.

- [ ] Create `lib/salesforce/soql.ts`:

```ts
/**
 * The ONLY sanctioned way to build SOQL in this repo (Global Constraints).
 * A tagged template: every interpolated value is escaped/formatted by type.
 * Never concatenate user input into a SOQL string by hand.
 */

/** Backslashes first, then single quotes — order matters. */
function escapeSoqlString(value: string): string {
  return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}

function formatValue(value: string | number | Date | string[]): string {
  if (value instanceof Date) {
    // SOQL datetime literals are unquoted: WHERE SystemModstamp > 2026-07-16T10:30:00.000Z
    return value.toISOString();
  }
  if (typeof value === 'number') {
    if (!Number.isFinite(value)) {
      throw new Error(`soql: non-finite number cannot be interpolated: ${value}`);
    }
    return String(value);
  }
  if (Array.isArray(value)) {
    if (value.length === 0) {
      throw new Error('soql: empty array cannot be interpolated (IN () is invalid SOQL)');
    }
    return `(${value.map((v) => `'${escapeSoqlString(v)}'`).join(',')})`;
  }
  return `'${escapeSoqlString(value)}'`;
}

export function soql(
  strings: TemplateStringsArray,
  ...values: (string | number | Date | string[])[]
): string {
  let out = strings[0];
  for (let i = 0; i < values.length; i += 1) {
    out += formatValue(values[i]) + strings[i + 1];
  }
  return out;
}
```

- [ ] Run `npx vitest run tests/unit/soql.test.ts` — expected PASS (10 tests).

- [ ] Write the failing leak test. Create `tests/unit/allowlist-leak.test.ts`:

```ts
import { describe, expect, it } from 'vitest';
import { DENYLIST } from '../../lib/sync/denylist';
import {
  ALLOWLIST,
  SYNC_OBJECTS,
  selectClause,
  whereClause,
} from '../../lib/sync/fieldAllowlist';

/**
 * A relationship field like 'Order__r.Account__c' must be checked as-is AND by
 * its last segment ('Account__c'), so a denylisted field can't sneak in behind
 * a relationship path.
 */
function candidates(field: string): string[] {
  const segments = field.split('.');
  const last = segments[segments.length - 1];
  return last === field ? [field] : [field, last];
}

/**
 * Wildcard-denied families from the design brief that cannot be enumerated
 * field-by-field (Vendor_Additional_*, Netsuite_* vendor/PO plumbing, QB_*,
 * margin formulas, gateway tokens). Patterns only — literal sensitive field
 * names may appear in lib/sync/denylist.ts and nowhere else.
 */
const DENIED_FAMILIES: readonly RegExp[] = [
  /^Vendor_/i,
  /^Netsuite_/i,
  /^QB_/i,
  /Margin/i,
  /_Token__c$/i,
];

describe('field allowlist <-> denylist leak test', () => {
  it('covers all nine sync objects with non-empty allowlists', () => {
    expect(SYNC_OBJECTS.length).toBe(9);
    for (const obj of SYNC_OBJECTS) {
      expect(ALLOWLIST[obj].length, `${obj} allowlist is empty`).toBeGreaterThan(0);
    }
  });

  it('every allowlist contains Id, IsDeleted and SystemModstamp', () => {
    for (const obj of SYNC_OBJECTS) {
      for (const required of ['Id', 'IsDeleted', 'SystemModstamp']) {
        expect(ALLOWLIST[obj], `${obj} is missing ${required}`).toContain(required);
      }
    }
  });

  it('no allowlist contains duplicates', () => {
    for (const obj of SYNC_OBJECTS) {
      const list = ALLOWLIST[obj];
      expect(new Set(list).size, `${obj} has duplicate entries`).toBe(list.length);
    }
  });

  it('denylist is intact: no duplicates and every catalogued family represented', () => {
    expect(new Set(DENYLIST).size).toBe(DENYLIST.length);
    expect(DENYLIST.length).toBeGreaterThanOrEqual(40);
    for (const family of DENIED_FAMILIES) {
      expect(
        DENYLIST.some((f) => family.test(f)),
        `no denylist entry matches ${String(family)}`,
      ).toBe(true);
    }
  });

  it('no allowlisted field (or its relationship tail) appears in the denylist', () => {
    const denied = new Set(DENYLIST);
    for (const obj of SYNC_OBJECTS) {
      for (const field of ALLOWLIST[obj]) {
        for (const candidate of candidates(field)) {
          expect(
            denied.has(candidate),
            `${obj}.${field} leaks denylisted field ${candidate}`,
          ).toBe(false);
        }
      }
    }
  });

  it('no allowlisted field belongs to a wildcard-denied family', () => {
    for (const obj of SYNC_OBJECTS) {
      for (const field of ALLOWLIST[obj]) {
        for (const candidate of candidates(field)) {
          for (const family of DENIED_FAMILIES) {
            expect(
              family.test(candidate),
              `${obj}.${field} matches denied family ${String(family)}`,
            ).toBe(false);
          }
        }
      }
    }
  });

  it('object-level exclusions: homonym fields exposed elsewhere never appear on the sensitive object', () => {
    // These fields are deliberately absent from (or unprotected by) the global
    // DENYLIST intersection because the same API name is legitimately exposed
    // on another object (see the module notes in lib/sync/denylist.ts) — so pin
    // the sensitive object's allowlist directly.
    expect(ALLOWLIST['Transaction__c']).not.toContain('Expiration_Date__c'); // allowed on Credit_Card__c only
    expect(ALLOWLIST['Transaction__c']).not.toContain('Authorization_Code__c');
    expect(ALLOWLIST['Customer_Orders__c']).not.toContain('Tax_Amount__c'); // allowed on Netsuite_Transactions__c only
    expect(ALLOWLIST['Customer_Orders__c']).not.toContain('Balance__c');
  });

  it('selectClause joins the allowlist verbatim', () => {
    expect(selectClause('Account')).toBe(
      'Id, Name, RecordType.DeveloperName, IsDeleted, SystemModstamp',
    );
  });

  it('whereClause returns the static per-object filters', () => {
    expect(whereClause('Account')).toBe(
      "RecordType.DeveloperName IN ('Business_Account','PersonAccount')",
    );
    expect(whereClause('Case')).toBe(
      "RecordTypeId = '0123o000001cpHZAAY' AND (Origin IN ('Email','Phone','Web','Customer Portal') OR Type IN ('Cancellation','Delivery ETA','Delivery Request','Removal Request','Swap Dumpster','Invoice Issue','Customer Inquiry','Potty Service','Final Removal','Refund'))",
    );
    expect(whereClause('Netsuite_Transactions__c')).toBe("Type__c = 'Invoice'");
    expect(whereClause('Contact')).toBe('');
    expect(whereClause('Customer_Orders__c')).toBe('');
    expect(whereClause('Order_Line_Item__c')).toBe('');
    expect(whereClause('Transaction__c')).toBe('');
    expect(whereClause('Vendor_Delivery_Notification__c')).toBe('');
    expect(whereClause('Credit_Card__c')).toBe('');
  });
});
```

- [ ] Run `npx vitest run tests/unit/allowlist-leak.test.ts` — expected FAILURE: module-resolution errors for `../../lib/sync/denylist` and `../../lib/sync/fieldAllowlist`.

- [ ] Create `lib/sync/denylist.ts` (the ONLY file in the repo where these field names may appear — see Global Constraints):

```ts
/**
 * Every never-expose Salesforce field catalogued in docs/design-input-brief.md
 * (§1 order/OLI/case/delivery denylists, §6 security flags).
 *
 * This module exists solely to feed tests/unit/allowlist-leak.test.ts. Nothing
 * at runtime imports it; nothing may ever move a name from here into an
 * allowlist without deleting it here in the same reviewed diff — which is
 * exactly the friction we want.
 *
 * Deliberate omissions (homonym fields legitimately exposed on OTHER objects,
 * protected instead by simply not being allowlisted on the sensitive object):
 * - Transaction__c's card-expiry field (same API name is a display field on
 *   Credit_Card__c).
 * - Customer_Orders__c's deprecated tax twin (same API name is the real tax
 *   field on Netsuite_Transactions__c).
 * Wildcard families (Vendor_Additional_*, Netsuite_* vendor/PO, QB_*) are
 * additionally enforced by regex in the leak test.
 */
export const DENYLIST: readonly string[] = [
  // ── Vendor identity / contact / cost (Customer_Orders__c, Order_Line_Item__c, Case mirrors) ──
  'Vendor__c',
  'Vendor_Contact__c',
  'Vendor_Contact_OLI__c',
  'Vendor_Email__c',
  'Vendor_Phone__c',
  'Vendor_Rate__c',
  'Vendor_Delivery_Fee__c',
  'Vendor_Line_Cost__c',
  'Vendor_Additional_Days__c',
  'Vendor_Additional_Ton__c',
  'Vendor_Additional_Fees__c',
  'Vendor_Swap_Cost__c',
  'Vendor_Invoice_Number__c',
  'VendorsCSV__c',
  'Line_Note_to_Vendor__c',
  'Notification_Sent_to__c', // Vendor_Delivery_Notification__c — never synced
  // ── Margin fields ──
  'Total_Proj_Marg_Sum__c',
  'CO_Actual_Margin__c',
  'Projected_Margin__c',
  'Proj_Marg_Percentage__c',
  'Total_Projected_Margin__c',
  'Margin_LP_LC__c',
  // ── Cost / internal-ops fields ──
  'Actual_Cost__c',
  'Check_Request_Total__c',
  'CO_Check_Request_Total__c',
  'Procurement_Notes__c',
  'Provider_Notes__c',
  'Claimed_By__c',
  'Related_Disposal_Facility__c',
  'Internal_Notes__c',
  'Internal_Order_Information__c',
  'Internal_Account_Information_2__c',
  // ── NetSuite / QuickBooks vendor & PO plumbing (named in the brief) ──
  'Netsuite_Sync_Date__c',
  'NetSuite_Id__c',
  'QB_Status__c',
  'QB_Update_Log__c',
  // ── Card-sensitive / payment-gateway fields ──
  'Security_Code__c',
  'Credit_Card_Number__c',
  'Payment_Token__c',
  'Authorize_net_Profile_Id__c',
  'LDR_Site_Services_Token__c',
  'Payscape_Payload__c',
  'Authorization_Code__c',
];
```

- [ ] Create `lib/sync/fieldAllowlist.ts`:

```ts
/**
 * THE field allowlist. Every SOQL SELECT the sync worker or backfill issues is
 * generated from ALLOWLIST via selectClause(); a field that is not listed here
 * physically never enters the portal database. Field API names come from
 * docs/design-input-brief.md §1; adding a field requires it to survive
 * tests/unit/allowlist-leak.test.ts (zero intersection with the denylist and
 * with the wildcard-denied families).
 */
export const SYNC_OBJECTS = [
  'Account',
  'Contact',
  'Customer_Orders__c',
  'Order_Line_Item__c',
  'Case',
  'Transaction__c',
  'Netsuite_Transactions__c',
  'Vendor_Delivery_Notification__c',
  'Credit_Card__c',
] as const;

export type SyncObject = (typeof SYNC_OBJECTS)[number];

export const ALLOWLIST: Record<SyncObject, readonly string[]> = {
  Account: ['Id', 'Name', 'RecordType.DeveloperName', 'IsDeleted', 'SystemModstamp'],

  Contact: [
    'Id',
    'AccountId',
    'Email',
    'FirstName',
    'LastName',
    'Phone',
    'IsPersonAccount',
    'IsDeleted',
    'SystemModstamp',
  ],

  Customer_Orders__c: [
    'Id',
    'Name', // order number
    'Company__c',
    'Account__c',
    'Shipping_Street__c',
    'Shipping_City__c',
    'Shipping_State__c',
    'Shipping_Zip__c',
    'Site_Address__c',
    'Store_Name__c',
    'Store_Number__c',
    'Location_Name_Other__c',
    'Site_Contact__c',
    'Site_Contact_Telephone__c',
    'Setting_Instructions__c',
    'Customer_Order_Status__c',
    'Delivery_Date__c',
    'First_OLI_Delivery_Date__c',
    'Latest_OLI_End_Date__c',
    'Order_Completed_Date__c',
    'LIne_Total_Amount__c', // sic — the capital I typo is the real API name
    'Tax_Amount_Oli__c',
    'Total_Amount_After_Tax__c',
    'Charged_Amount__c',
    'Credited_Amount__c',
    'NewBalance__c',
    'Customer_PO__c',
    'Customer_Ref__c',
    'Notes_to_Customer__c',
    'IsDeleted',
    'SystemModstamp',
  ],

  Order_Line_Item__c: [
    'Id',
    'Order__c',
    'RecordType.Name', // placement vs charge-adjustment
    'Job_Type__c',
    'Product__c',
    'Size__c',
    'Quantity__c',
    'Material__c',
    'Service__c',
    'Service_Day__c',
    'Service_Day_2__c',
    'Service_Day_3__c',
    'Service_Day_4__c',
    'KAM_Deliveries_Removals__c',
    'Delivery_Confirmed__c',
    'Delivery_Confirmed_Date__c',
    'Removal_Confirmed__c',
    'Removal_Confirmed_Date__c',
    'If_cancelled_why__c',
    'Delivery_Date__c',
    'Expected_Delivery_Time__c',
    'End_Date__c',
    'Projected_End_Date__c',
    'Swap_Date__c',
    'Rental_Days__c',
    'Price__c',
    'Delivery_Fee__c',
    'Tons_Included__c',
    'Price_Per_Ton__c',
    'Price_Per_Additional_Days__c',
    'Additional_Days__c',
    'Additional_Ton__c',
    'Additional_Fees__c',
    'Swap_Charge__c',
    'LIne_Total_Amount__c', // sic
    'Line_Tax_Amount__c',
    'Line_Total_Amount_After_Tax__c',
    'Amount_Charged__c',
    'Open_Balance__c',
    'Payment_Received__c',
    'Line_Notes_to_Customer__c',
    'IsDeleted',
    'SystemModstamp',
  ],

  Case: [
    'Id',
    'AccountId',
    'CaseNumber',
    'Subject',
    'Description',
    'Type',
    'Status',
    'Priority',
    'Resolution__c',
    'Update_s__c',
    'Origin',
    'Order_Line_Item__c',
    'Customer_Order__c',
    'CreatedDate',
    'ClosedDate',
    'IsDeleted',
    'SystemModstamp',
  ],

  Transaction__c: [
    'Id',
    'Name', // T-######
    'Type__c',
    'Amount__c',
    'Pre_tax_Amount__c',
    'Tax__c',
    'Credit_Amount__c',
    'Credit_Card_Last_4__c',
    'Credit_Card_Type__c',
    'Item_Description__c',
    'Payment_Reference__c',
    'Order__c',
    'Order__r.Account__c', // no direct Account lookup — scope through the order
    'Order_Line_Item__c',
    'Voided__c',
    'CreatedDate',
    'IsDeleted',
    'SystemModstamp',
  ],

  Netsuite_Transactions__c: [
    'Id',
    'Name',
    'Amount_Total__c',
    'Amount_Paid__c',
    'Amount_Due__c',
    'Tax_Amount__c',
    'Transaction_Date__c',
    'Transaction_Due_date__c',
    'Status__c',
    'Type__c',
    'Payment_Method__c',
    'Memo__c',
    'Order__c',
    'Order__r.Account__c', // direct Account__c is unreliable (frequently null)
    'IsDeleted',
    'SystemModstamp',
  ],

  Vendor_Delivery_Notification__c: [
    'Id',
    'Order__c',
    'Name__c', // → OLI
    'Is_Responded__c',
    'Responded_on__c',
    'Response__c',
    'IsDeleted',
    'SystemModstamp',
  ],

  Credit_Card__c: [
    'Id',
    'Name',
    'Card_Number_Last_4__c',
    'Credit_Card_Type__c',
    'Expiration_Date__c', // display string only
    'Primary__c',
    'Account__c',
    'Company__c',
    'IsDeleted',
    'SystemModstamp',
  ],
};

/**
 * Static, non-user-derived per-object filters. These contain no interpolated
 * values, so they are safe as constants; anything dynamic (high-water marks,
 * account ids) MUST go through the soql tagged template.
 */
const WHERE: Record<SyncObject, string> = {
  Account: "RecordType.DeveloperName IN ('Business_Account','PersonAccount')",
  Contact: '',
  Customer_Orders__c: '',
  Order_Line_Item__c: '',
  Case:
    "RecordTypeId = '0123o000001cpHZAAY' AND (Origin IN ('Email','Phone','Web','Customer Portal') OR Type IN ('Cancellation','Delivery ETA','Delivery Request','Removal Request','Swap Dumpster','Invoice Issue','Customer Inquiry','Potty Service','Final Removal','Refund'))",
  Transaction__c: '',
  Netsuite_Transactions__c: "Type__c = 'Invoice'",
  Vendor_Delivery_Notification__c: '',
  Credit_Card__c: '',
};

export function selectClause(obj: SyncObject): string {
  return ALLOWLIST[obj].join(', ');
}

export function whereClause(obj: SyncObject): string {
  return WHERE[obj];
}
```

- [ ] Run `npx vitest run tests/unit/allowlist-leak.test.ts` — expected PASS (9 tests).
- [ ] Run `npx vitest run tests/unit` and `npx tsc --noEmit` — expected: all unit tests pass, no type errors.
- [ ] Commit:

```bash
git add lib/salesforce/soql.ts lib/sync/denylist.ts lib/sync/fieldAllowlist.ts tests/unit/soql.test.ts tests/unit/allowlist-leak.test.ts
git commit -m "feat: SOQL tagged template, sync field allowlist, denylist leak test"
```

### Task 5: Salesforce client (auth, real REST client, mock)

**Files:**
- Create: `lib/salesforce/auth.ts`
- Create: `lib/salesforce/client.ts`
- Create: `lib/salesforce/mock.ts`
- Test: `tests/unit/sf-auth.test.ts`
- Test: `tests/unit/sf-client.test.ts`
- Test: `tests/unit/sf-mock.test.ts`

**Interfaces:**
- Consumes:
  - `getSecret(name: string): Promise<string>` from `lib/secrets.ts` (Task 3). It resolves env vars first, so unit tests satisfy it by setting `process.env.SF_CLIENT_ID` / `process.env.SF_CLIENT_SECRET` — no AWS calls.
- Produces (sync tasks, backfill, and service-request push rely on these exactly):
  - `export type SfRecord = Record<string, unknown> & { attributes?: unknown; Id: string }`
  - `export interface SfClient { queryAll(soqlText: string): Promise<SfRecord[]>; create(objectName: string, fields: Record<string, unknown>): Promise<string> }`
  - `export function getSfClient(): Promise<SfClient>` — mock implementation whenever `process.env.SF_MODE !== 'real'`
  - `export class SfError extends Error { readonly status: number; readonly body: string }`
  - `export async function getAccessToken(): Promise<string>` and `export function _clearTokenCacheForTests(): void` from `lib/salesforce/auth.ts`
  - `export const mockSf = { reset(), addRecords(obj, rows), created }` and `export function getMockSfClient(): SfClient` from `lib/salesforce/mock.ts` — integration tests seed SF data through `mockSf`.

**Notes:**
- `SF_INSTANCE_URL` is non-secret config (a URL, like `SF_MODE`) and is read from `process.env` directly; only the client id/secret go through `getSecret()`. Add `SF_INSTANCE_URL=` to `.env.example` if Task 1 did not already.
- The mock does NOT parse SOQL. It supports exactly the query shapes the sync worker emits: object name via the `FROM` clause, plus an optional strict `SystemModstamp > <ISO datetime>` floor extracted by regex. Static filters (record types, origins, `Type__c='Invoice'`) are ignored — tests must seed only rows they expect back. This limitation is documented in the module.

**Steps:**

- [ ] Write the failing auth test. Create `tests/unit/sf-auth.test.ts`:

```ts
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { _clearTokenCacheForTests, getAccessToken } from '../../lib/salesforce/auth';

const ENV_KEYS = ['SF_INSTANCE_URL', 'SF_CLIENT_ID', 'SF_CLIENT_SECRET'] as const;
let savedEnv: Record<string, string | undefined>;

function tokenResponse(token: string): Response {
  return new Response(JSON.stringify({ access_token: token }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });
}

describe('getAccessToken', () => {
  beforeEach(() => {
    savedEnv = {};
    for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
    process.env.SF_INSTANCE_URL = 'https://example.my.salesforce.com';
    process.env.SF_CLIENT_ID = 'test-client-id';
    process.env.SF_CLIENT_SECRET = 'test-client-secret';
    _clearTokenCacheForTests();
  });

  afterEach(() => {
    for (const key of ENV_KEYS) {
      const value = savedEnv[key];
      if (value === undefined) delete process.env[key];
      else process.env[key] = value;
    }
    vi.unstubAllGlobals();
    vi.useRealTimers();
    _clearTokenCacheForTests();
  });

  it('POSTs client credentials to the token endpoint and returns the token', async () => {
    const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
      expect(String(input)).toBe('https://example.my.salesforce.com/services/oauth2/token');
      expect(init?.method).toBe('POST');
      const body = String(init?.body);
      expect(body).toContain('grant_type=client_credentials');
      expect(body).toContain('client_id=test-client-id');
      expect(body).toContain('client_secret=test-client-secret');
      return tokenResponse('tok-1');
    };
    const fetchMock = vi.fn(fetchImpl);
    vi.stubGlobal('fetch', fetchMock);

    await expect(getAccessToken()).resolves.toBe('tok-1');
    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  it('caches the token for calls within 50 minutes', async () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-07-16T10:00:00.000Z'));
    const fetchMock = vi.fn(async (): Promise<Response> => tokenResponse('tok-1'));
    vi.stubGlobal('fetch', fetchMock);

    await getAccessToken();
    vi.advanceTimersByTime(49 * 60 * 1000);
    await expect(getAccessToken()).resolves.toBe('tok-1');
    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  it('refreshes the token after 50 minutes', async () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-07-16T10:00:00.000Z'));
    let counter = 0;
    const fetchMock = vi.fn(async (): Promise<Response> => tokenResponse(`tok-${(counter += 1)}`));
    vi.stubGlobal('fetch', fetchMock);

    await expect(getAccessToken()).resolves.toBe('tok-1');
    vi.advanceTimersByTime(51 * 60 * 1000);
    await expect(getAccessToken()).resolves.toBe('tok-2');
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it('_clearTokenCacheForTests forces a refetch', async () => {
    let counter = 0;
    const fetchMock = vi.fn(async (): Promise<Response> => tokenResponse(`tok-${(counter += 1)}`));
    vi.stubGlobal('fetch', fetchMock);

    await expect(getAccessToken()).resolves.toBe('tok-1');
    _clearTokenCacheForTests();
    await expect(getAccessToken()).resolves.toBe('tok-2');
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it('throws on a non-2xx token response', async () => {
    const fetchMock = vi.fn(async (): Promise<Response> => new Response('denied', { status: 400 }));
    vi.stubGlobal('fetch', fetchMock);

    await expect(getAccessToken()).rejects.toThrow(/token request failed: 400/);
  });
});
```

- [ ] Run `npx vitest run tests/unit/sf-auth.test.ts` — expected FAILURE: module-resolution error (`Failed to resolve import "../../lib/salesforce/auth"`).

- [ ] Create `lib/salesforce/auth.ts`:

```ts
import { getSecret } from '../secrets';

/** SF access tokens live ~2h under default session settings; refresh well before. */
const TOKEN_TTL_MS = 50 * 60 * 1000;

interface TokenCache {
  token: string;
  fetchedAt: number;
}

let cache: TokenCache | null = null;

export function _clearTokenCacheForTests(): void {
  cache = null;
}

/** Non-secret config (a URL): read from env directly, like SF_MODE. */
export function getSfInstanceUrl(): string {
  const url = process.env.SF_INSTANCE_URL;
  if (!url) {
    throw new Error('SF_INSTANCE_URL is not set (required when SF_MODE=real)');
  }
  return url.replace(/\/+$/, '');
}

export async function getAccessToken(): Promise<string> {
  if (cache && Date.now() - cache.fetchedAt < TOKEN_TTL_MS) {
    return cache.token;
  }
  const [clientId, clientSecret] = await Promise.all([
    getSecret('SF_CLIENT_ID'),
    getSecret('SF_CLIENT_SECRET'),
  ]);
  const body = new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: clientId,
    client_secret: clientSecret,
  });
  const res = await fetch(`${getSfInstanceUrl()}/services/oauth2/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: body.toString(),
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Salesforce token request failed: ${res.status} ${text}`);
  }
  const json = (await res.json()) as { access_token?: string };
  if (!json.access_token) {
    throw new Error('Salesforce token response missing access_token');
  }
  cache = { token: json.access_token, fetchedAt: Date.now() };
  return cache.token;
}
```

- [ ] Run `npx vitest run tests/unit/sf-auth.test.ts` — expected PASS (5 tests).

- [ ] Write the failing mock test. Create `tests/unit/sf-mock.test.ts`:

```ts
import { beforeEach, describe, expect, it } from 'vitest';
import { getMockSfClient, mockSf } from '../../lib/salesforce/mock';

describe('mock Salesforce client', () => {
  beforeEach(() => {
    mockSf.reset();
  });

  it('queryAll returns seeded rows for the FROM object, sorted by SystemModstamp', async () => {
    mockSf.addRecords('Customer_Orders__c', [
      { Id: 'a02000000000002AAA', SystemModstamp: '2026-07-16T12:00:00.000Z' },
      { Id: 'a02000000000001AAA', SystemModstamp: '2026-07-15T12:00:00.000Z' },
    ]);
    mockSf.addRecords('Case', [
      { Id: '500000000000001AAA', SystemModstamp: '2026-07-16T00:00:00.000Z' },
    ]);

    const rows = await getMockSfClient().queryAll(
      'SELECT Id FROM Customer_Orders__c ORDER BY SystemModstamp',
    );
    expect(rows.map((r) => r.Id)).toEqual(['a02000000000001AAA', 'a02000000000002AAA']);
  });

  it('applies a strict SystemModstamp > floor when present in the WHERE clause', async () => {
    mockSf.addRecords('Contact', [
      { Id: '003000000000001AAA', SystemModstamp: '2026-07-15T00:00:00.000Z' },
      { Id: '003000000000002AAA', SystemModstamp: '2026-07-16T00:00:00.000Z' },
      { Id: '003000000000003AAA', SystemModstamp: '2026-07-16T08:00:00.000Z' },
    ]);

    const rows = await getMockSfClient().queryAll(
      'SELECT Id FROM Contact WHERE SystemModstamp > 2026-07-16T00:00:00.000Z ORDER BY SystemModstamp',
    );
    // strictly greater: the row equal to the floor is excluded
    expect(rows.map((r) => r.Id)).toEqual(['003000000000003AAA']);
  });

  it('create pushes into mockSf.created and returns an 18-char mock Id', async () => {
    const client = getMockSfClient();
    const id1 = await client.create('Case', { Subject: 'First' });
    const id2 = await client.create('Case', { Subject: 'Second' });

    expect(id1).toBe('MOCKID000000000001');
    expect(id2).toBe('MOCKID000000000002');
    expect(id1).toHaveLength(18);
    expect(mockSf.created).toEqual([
      { obj: 'Case', fields: { Subject: 'First' } },
      { obj: 'Case', fields: { Subject: 'Second' } },
    ]);
  });

  it('reset clears seeded rows, created records, and the id counter', async () => {
    mockSf.addRecords('Account', [
      { Id: '001000000000001AAA', SystemModstamp: '2026-07-16T00:00:00.000Z' },
    ]);
    await getMockSfClient().create('Case', {});

    mockSf.reset();

    expect(mockSf.created).toHaveLength(0);
    expect(await getMockSfClient().queryAll('SELECT Id FROM Account')).toEqual([]);
    expect(await getMockSfClient().create('Case', {})).toBe('MOCKID000000000001');
  });
});
```

- [ ] Run `npx vitest run tests/unit/sf-mock.test.ts` — expected FAILURE: module-resolution error (`Failed to resolve import "../../lib/salesforce/mock"`).

- [ ] Create `lib/salesforce/mock.ts`:

```ts
import type { SfClient, SfRecord } from './client';

/**
 * In-memory Salesforce used whenever SF_MODE !== 'real' (the default in dev
 * and tests). Seed rows with mockSf.addRecords(); inspect writes via
 * mockSf.created; call mockSf.reset() between tests.
 *
 * LIMITATION (deliberate): this mock does not parse SOQL. It supports exactly
 * the query shapes the sync worker emits —
 *   - the object name is taken from the FROM clause, and
 *   - an optional strict floor "SystemModstamp > <ISO datetime>" is extracted
 *     by regex.
 * All other WHERE conditions (record-type slices, origin lists, Type__c
 * filters) are ignored: tests must seed only rows they expect back.
 */

export interface MockCreatedRecord {
  obj: string;
  fields: Record<string, unknown>;
}

const store = new Map<string, SfRecord[]>();
const createdRecords: MockCreatedRecord[] = [];
let idCounter = 0;

export const mockSf = {
  reset(): void {
    store.clear();
    createdRecords.length = 0;
    idCounter = 0;
  },
  addRecords(obj: string, rows: SfRecord[]): void {
    store.set(obj, [...(store.get(obj) ?? []), ...rows]);
  },
  created: createdRecords,
};

function objectFromSoql(soqlText: string): string {
  const m = /\bFROM\s+([A-Za-z0-9_]+)/i.exec(soqlText);
  if (!m) {
    throw new Error(`mock queryAll: cannot parse object name from: ${soqlText}`);
  }
  return m[1];
}

function modstampFloor(soqlText: string): Date | null {
  const m =
    /SystemModstamp\s*>\s*([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+(?:Z|[+-][0-9]{2}:?[0-9]{2}))/.exec(
      soqlText,
    );
  return m ? new Date(m[1]) : null;
}

function modstampMs(record: SfRecord): number {
  return new Date(String(record.SystemModstamp)).getTime();
}

export function getMockSfClient(): SfClient {
  return {
    async queryAll(soqlText: string): Promise<SfRecord[]> {
      const obj = objectFromSoql(soqlText);
      const rows = store.get(obj) ?? [];
      const floor = modstampFloor(soqlText);
      const filtered = floor ? rows.filter((r) => modstampMs(r) > floor.getTime()) : [...rows];
      return filtered.sort((a, b) => modstampMs(a) - modstampMs(b));
    },
    async create(objectName: string, fields: Record<string, unknown>): Promise<string> {
      createdRecords.push({ obj: objectName, fields });
      idCounter += 1;
      // 'MOCKID' (6 chars) + 12-digit counter = 18-char SF-Id-shaped string
      return `MOCKID${String(idCounter).padStart(12, '0')}`;
    },
  };
}
```

- [ ] Write the failing client test. Create `tests/unit/sf-client.test.ts`:

```ts
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { _clearTokenCacheForTests } from '../../lib/salesforce/auth';
import { getSfClient, SfError } from '../../lib/salesforce/client';
import { mockSf } from '../../lib/salesforce/mock';

const ENV_KEYS = ['SF_INSTANCE_URL', 'SF_CLIENT_ID', 'SF_CLIENT_SECRET', 'SF_MODE'] as const;
let savedEnv: Record<string, string | undefined>;

function jsonResponse(body: unknown, status = 200): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { 'Content-Type': 'application/json' },
  });
}

beforeEach(() => {
  savedEnv = {};
  for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
  process.env.SF_INSTANCE_URL = 'https://example.my.salesforce.com';
  process.env.SF_CLIENT_ID = 'test-client-id';
  process.env.SF_CLIENT_SECRET = 'test-client-secret';
  _clearTokenCacheForTests();
});

afterEach(() => {
  for (const key of ENV_KEYS) {
    const value = savedEnv[key];
    if (value === undefined) delete process.env[key];
    else process.env[key] = value;
  }
  vi.unstubAllGlobals();
  _clearTokenCacheForTests();
});

describe('getSfClient (real mode)', () => {
  beforeEach(() => {
    process.env.SF_MODE = 'real';
  });

  it('queryAll follows nextRecordsUrl until done and concatenates pages', async () => {
    const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
      const url = String(input);
      if (url.endsWith('/services/oauth2/token')) {
        return jsonResponse({ access_token: 'tok' });
      }
      if (url.includes('/services/data/v59.0/queryAll?q=')) {
        expect(url).toContain(encodeURIComponent('SELECT Id FROM Account'));
        return jsonResponse({
          done: false,
          nextRecordsUrl: '/services/data/v59.0/query/01gRO-2000',
          records: [{ Id: '001000000000001AAA' }],
        });
      }
      if (url.endsWith('/services/data/v59.0/query/01gRO-2000')) {
        return jsonResponse({ done: true, records: [{ Id: '001000000000002AAA' }] });
      }
      throw new Error(`unexpected fetch: ${url}`);
    };
    vi.stubGlobal('fetch', vi.fn(fetchImpl));

    const client = await getSfClient();
    const records = await client.queryAll('SELECT Id FROM Account');
    expect(records.map((r) => r.Id)).toEqual(['001000000000001AAA', '001000000000002AAA']);
  });

  it('queryAll throws SfError with status and body on a non-2xx response', async () => {
    const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
      const url = String(input);
      if (url.endsWith('/services/oauth2/token')) {
        return jsonResponse({ access_token: 'tok' });
      }
      return new Response('[{"message":"MALFORMED_QUERY"}]', { status: 400 });
    };
    vi.stubGlobal('fetch', vi.fn(fetchImpl));

    const client = await getSfClient();
    const err = await client.queryAll('SELECT bogus').catch((e: unknown) => e);
    expect(err).toBeInstanceOf(SfError);
    expect((err as SfError).status).toBe(400);
    expect((err as SfError).body).toContain('MALFORMED_QUERY');
  });

  it('create POSTs fields with a bearer token and returns the new Id', async () => {
    let capturedBody = '';
    let capturedAuth: string | undefined;
    const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
      const url = String(input);
      if (url.endsWith('/services/oauth2/token')) {
        return jsonResponse({ access_token: 'tok' });
      }
      if (url.endsWith('/services/data/v59.0/sobjects/Case')) {
        expect(init?.method).toBe('POST');
        capturedBody = String(init?.body);
        capturedAuth = new Headers(init?.headers).get('Authorization') ?? undefined;
        return jsonResponse({ id: '500000000000001AAA', success: true, errors: [] }, 201);
      }
      throw new Error(`unexpected fetch: ${url}`);
    };
    vi.stubGlobal('fetch', vi.fn(fetchImpl));

    const client = await getSfClient();
    const id = await client.create('Case', { Subject: 'Help' });
    expect(id).toBe('500000000000001AAA');
    expect(JSON.parse(capturedBody)).toEqual({ Subject: 'Help' });
    expect(capturedAuth).toBe('Bearer tok');
  });

  it('create throws SfError carrying status and body on failure', async () => {
    const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
      const url = String(input);
      if (url.endsWith('/services/oauth2/token')) {
        return jsonResponse({ access_token: 'tok' });
      }
      return new Response('[{"message":"REQUIRED_FIELD_MISSING"}]', { status: 400 });
    };
    vi.stubGlobal('fetch', vi.fn(fetchImpl));

    const client = await getSfClient();
    const err = await client.create('Case', {}).catch((e: unknown) => e);
    expect(err).toBeInstanceOf(SfError);
    expect((err as SfError).status).toBe(400);
    expect((err as SfError).body).toContain('REQUIRED_FIELD_MISSING');
  });
});

describe('getSfClient (mock mode)', () => {
  it('returns the mock client when SF_MODE is not "real"', async () => {
    delete process.env.SF_MODE;
    mockSf.reset();
    mockSf.addRecords('Account', [
      { Id: '001000000000001AAA', SystemModstamp: '2026-07-16T00:00:00.000Z' },
    ]);

    const client = await getSfClient();
    const rows = await client.queryAll('SELECT Id FROM Account');
    expect(rows).toHaveLength(1);
    expect(rows[0].Id).toBe('001000000000001AAA');
  });
});
```

- [ ] Run `npx vitest run tests/unit/sf-client.test.ts` — expected FAILURE: module-resolution error (`Failed to resolve import "../../lib/salesforce/client"`). (`sf-mock.test.ts` also still fails until `client.ts` exists, because `mock.ts` imports its types.)

- [ ] Create `lib/salesforce/client.ts`:

```ts
import { getAccessToken, getSfInstanceUrl } from './auth';
import { getMockSfClient } from './mock';

export type SfRecord = Record<string, unknown> & { attributes?: unknown; Id: string };

export interface SfClient {
  /** Runs the query via the queryAll endpoint (includes IsDeleted=true rows), auto-following nextRecordsUrl. */
  queryAll(soqlText: string): Promise<SfRecord[]>;
  /** Creates one record; returns the new 18-char Id; throws SfError on failure. */
  create(objectName: string, fields: Record<string, unknown>): Promise<string>;
}

export class SfError extends Error {
  readonly status: number;
  readonly body: string;

  constructor(message: string, status: number, body: string) {
    super(message);
    this.name = 'SfError';
    this.status = status;
    this.body = body;
  }
}

const API_VERSION = 'v59.0';

interface QueryResponse {
  done: boolean;
  nextRecordsUrl?: string;
  records: SfRecord[];
}

async function sfFetch(path: string, init?: RequestInit): Promise<Response> {
  const token = await getAccessToken();
  const url = path.startsWith('http') ? path : `${getSfInstanceUrl()}${path}`;
  return fetch(url, {
    ...init,
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
      ...(init?.headers ?? {}),
    },
  });
}

const realClient: SfClient = {
  async queryAll(soqlText: string): Promise<SfRecord[]> {
    const records: SfRecord[] = [];
    let path = `/services/data/${API_VERSION}/queryAll?q=${encodeURIComponent(soqlText)}`;
    for (;;) {
      const res = await sfFetch(path);
      if (!res.ok) {
        throw new SfError(`queryAll failed with status ${res.status}`, res.status, await res.text());
      }
      const page = (await res.json()) as QueryResponse;
      records.push(...page.records);
      if (page.done || !page.nextRecordsUrl) {
        return records;
      }
      path = page.nextRecordsUrl;
    }
  },

  async create(objectName: string, fields: Record<string, unknown>): Promise<string> {
    const res = await sfFetch(`/services/data/${API_VERSION}/sobjects/${objectName}`, {
      method: 'POST',
      body: JSON.stringify(fields),
    });
    const bodyText = await res.text();
    if (!res.ok) {
      throw new SfError(`create ${objectName} failed with status ${res.status}`, res.status, bodyText);
    }
    const json = JSON.parse(bodyText) as { id?: string; success?: boolean };
    if (!json.id || json.success === false) {
      throw new SfError(`create ${objectName} returned no id`, res.status, bodyText);
    }
    return json.id;
  },
};

export async function getSfClient(): Promise<SfClient> {
  if (process.env.SF_MODE !== 'real') {
    return getMockSfClient();
  }
  return realClient;
}
```

- [ ] Run `npx vitest run tests/unit/sf-mock.test.ts tests/unit/sf-client.test.ts` — expected PASS (4 + 5 tests).
- [ ] Run `npx vitest run tests/unit` and `npx tsc --noEmit` — expected: all unit tests pass, no type errors.
- [ ] Commit:

```bash
git add lib/salesforce/auth.ts lib/salesforce/client.ts lib/salesforce/mock.ts tests/unit/sf-auth.test.ts tests/unit/sf-client.test.ts tests/unit/sf-mock.test.ts
git commit -m "feat: salesforce client with cached client-credentials auth, paged queryAll, and in-memory mock"
```

### Task 6: Status mapping (raw SF picklists → customer-safe statuses)

**Files:**
- Create: `lib/mapping/status.ts`
- Test: `tests/unit/status.test.ts`

**Interfaces:**
- Consumes: nothing (pure functions; no database, no other lib code).
- Produces (the sync mappers and domain tasks rely on these exactly):
  - `export type UnitStatus = 'scheduled' | 'delivered' | 'removal_requested' | 'completed' | 'cancelled'`
  - `export function mapOrderStatus(raw: string | null): 'open' | 'closed'`
  - `export function mapUnitStatus(input: { lifecycleRaw: string | null; deliveryConfirmed: boolean; cancelReason: string | null }): UnitStatus`
  - `export function mapCaseStatus(input: { statusRaw: string | null; closedAt: Date | null }): 'open' | 'closed'`
  - `export function isChargeAdjustment(recordTypeName: string | null): boolean`

**Decision tables (authoritative — implement exactly, first match wins):**

| Function | Rule order |
|---|---|
| `mapOrderStatus` | `'Open'` → `'open'` (exact, case-sensitive); anything else including `null`, `''`, `'Closed'`, `'Audit'`, unknown → `'closed'` (fail closed) |
| `mapUnitStatus` | 1. `cancelReason` non-null and non-empty after trim → `'cancelled'` · 2. `lifecycleRaw` ∈ {`'Removal Complete'`, `'Job Complete'`} → `'completed'` · 3. `lifecycleRaw` = `'Removal Requested'` → `'removal_requested'` · 4. `lifecycleRaw` = `'Delivery Executed'` OR `deliveryConfirmed` → `'delivered'` · 5. else → `'scheduled'` (unknown/internal raw values like `'Vendor Queue'` fall through safely) |
| `mapCaseStatus` | `closedAt` non-null OR `statusRaw` ∈ {`'Closed'`, `'Resolved'`, `'Completed'`} → `'closed'`; else `'open'` |
| `isChargeAdjustment` | `recordTypeName` ∈ {`'Additional Tonnage'`, `'Extended Rental'`, `'Other Charges'`} → `true`; else (incl. `null`) → `false` |

**Steps:**

- [ ] Write the failing exhaustive decision-table test. Create `tests/unit/status.test.ts`:

```ts
import { describe, expect, it } from 'vitest';
import {
  isChargeAdjustment,
  mapCaseStatus,
  mapOrderStatus,
  mapUnitStatus,
  type UnitStatus,
} from '../../lib/mapping/status';

describe('mapOrderStatus', () => {
  it.each<[string | null, 'open' | 'closed']>([
    ['Open', 'open'],
    ['Closed', 'closed'],
    ['Audit', 'closed'],
    ['open', 'closed'], // case-sensitive: only the exact picklist value 'Open' is open
    ['Ready for Procurement', 'closed'], // unknown/internal raw values fail closed
    ['', 'closed'],
    [null, 'closed'],
  ])('maps %j to %s', (raw, expected) => {
    expect(mapOrderStatus(raw)).toBe(expected);
  });
});

describe('mapUnitStatus (precedence: cancelled > completed > removal_requested > delivered > scheduled)', () => {
  interface Row {
    lifecycleRaw: string | null;
    deliveryConfirmed: boolean;
    cancelReason: string | null;
    expected: UnitStatus;
  }
  const rows: Row[] = [
    // 1. cancelReason wins over everything
    { cancelReason: 'Customer changed plans', lifecycleRaw: 'Removal Complete', deliveryConfirmed: true, expected: 'cancelled' },
    { cancelReason: 'duplicate order', lifecycleRaw: null, deliveryConfirmed: false, expected: 'cancelled' },
    // empty / whitespace-only cancelReason is NOT a cancellation
    { cancelReason: '', lifecycleRaw: 'Removal Complete', deliveryConfirmed: false, expected: 'completed' },
    { cancelReason: '   ', lifecycleRaw: null, deliveryConfirmed: false, expected: 'scheduled' },
    // 2. completed lifecycles
    { cancelReason: null, lifecycleRaw: 'Removal Complete', deliveryConfirmed: false, expected: 'completed' },
    { cancelReason: null, lifecycleRaw: 'Job Complete', deliveryConfirmed: true, expected: 'completed' },
    // 3. removal requested (beats the delivered flag)
    { cancelReason: null, lifecycleRaw: 'Removal Requested', deliveryConfirmed: false, expected: 'removal_requested' },
    { cancelReason: null, lifecycleRaw: 'Removal Requested', deliveryConfirmed: true, expected: 'removal_requested' },
    // 4. delivered: lifecycle value OR confirmation flag
    { cancelReason: null, lifecycleRaw: 'Delivery Executed', deliveryConfirmed: false, expected: 'delivered' },
    { cancelReason: null, lifecycleRaw: null, deliveryConfirmed: true, expected: 'delivered' },
    { cancelReason: null, lifecycleRaw: 'Unconfirmed Delivery', deliveryConfirmed: true, expected: 'delivered' },
    // 5. fallback
    { cancelReason: null, lifecycleRaw: 'Unconfirmed Delivery', deliveryConfirmed: false, expected: 'scheduled' },
    { cancelReason: null, lifecycleRaw: 'Vendor Queue', deliveryConfirmed: false, expected: 'scheduled' }, // internal value never leaks
    { cancelReason: null, lifecycleRaw: null, deliveryConfirmed: false, expected: 'scheduled' },
  ];

  it.each(rows)(
    'lifecycle=$lifecycleRaw confirmed=$deliveryConfirmed cancel=$cancelReason -> $expected',
    (row) => {
      expect(
        mapUnitStatus({
          lifecycleRaw: row.lifecycleRaw,
          deliveryConfirmed: row.deliveryConfirmed,
          cancelReason: row.cancelReason,
        }),
      ).toBe(row.expected);
    },
  );
});

describe('mapCaseStatus', () => {
  const closedDate = new Date('2026-07-01T00:00:00.000Z');
  it.each<[{ statusRaw: string | null; closedAt: Date | null }, 'open' | 'closed']>([
    [{ statusRaw: 'New', closedAt: closedDate }, 'closed'], // closedAt wins regardless of raw status
    [{ statusRaw: null, closedAt: closedDate }, 'closed'],
    [{ statusRaw: 'Closed', closedAt: null }, 'closed'],
    [{ statusRaw: 'Resolved', closedAt: null }, 'closed'],
    [{ statusRaw: 'Completed', closedAt: null }, 'closed'],
    [{ statusRaw: 'New', closedAt: null }, 'open'],
    [{ statusRaw: 'Working', closedAt: null }, 'open'],
    [{ statusRaw: 'closed', closedAt: null }, 'open'], // exact picklist match only
    [{ statusRaw: '', closedAt: null }, 'open'],
    [{ statusRaw: null, closedAt: null }, 'open'],
  ])('%j -> %s', (input, expected) => {
    expect(mapCaseStatus(input)).toBe(expected);
  });
});

describe('isChargeAdjustment', () => {
  it.each<[string | null, boolean]>([
    ['Additional Tonnage', true],
    ['Extended Rental', true],
    ['Other Charges', true],
    ['Dumpster', false],
    ['Porta Potty', false],
    ['additional tonnage', false], // exact match only
    ['', false],
    [null, false],
  ])('%j -> %s', (recordTypeName, expected) => {
    expect(isChargeAdjustment(recordTypeName)).toBe(expected);
  });
});
```

- [ ] Run `npx vitest run tests/unit/status.test.ts` — expected FAILURE: module-resolution error (`Failed to resolve import "../../lib/mapping/status"`).

- [ ] Create `lib/mapping/status.ts`:

```ts
/**
 * Maps raw Salesforce picklist values to customer-safe statuses. Raw values
 * like 'Vendor Queue', 'Ready for Procurement', or 'Accounting' must never
 * render in the portal; every function here fails to the safest bucket on
 * unknown input.
 */

export type UnitStatus = 'scheduled' | 'delivered' | 'removal_requested' | 'completed' | 'cancelled';

/** Customer_Orders__c.Customer_Order_Status__c: only the exact value 'Open' is open. */
export function mapOrderStatus(raw: string | null): 'open' | 'closed' {
  return raw === 'Open' ? 'open' : 'closed';
}

const COMPLETED_LIFECYCLES: readonly string[] = ['Removal Complete', 'Job Complete'];

/**
 * Order_Line_Item__c unit status. Inputs:
 * - lifecycleRaw:       KAM_Deliveries_Removals__c
 * - deliveryConfirmed:  Delivery_Confirmed__c
 * - cancelReason:       If_cancelled_why__c
 * Precedence (first match wins): cancelled > completed > removal_requested > delivered > scheduled.
 */
export function mapUnitStatus(input: {
  lifecycleRaw: string | null;
  deliveryConfirmed: boolean;
  cancelReason: string | null;
}): UnitStatus {
  const { lifecycleRaw, deliveryConfirmed, cancelReason } = input;
  if (cancelReason !== null && cancelReason.trim() !== '') {
    return 'cancelled';
  }
  if (lifecycleRaw !== null && COMPLETED_LIFECYCLES.includes(lifecycleRaw)) {
    return 'completed';
  }
  if (lifecycleRaw === 'Removal Requested') {
    return 'removal_requested';
  }
  if (lifecycleRaw === 'Delivery Executed' || deliveryConfirmed) {
    return 'delivered';
  }
  return 'scheduled';
}

const CLOSED_CASE_STATUSES: readonly string[] = ['Closed', 'Resolved', 'Completed'];

/** Case status: a ClosedDate or a terminal raw status means closed; everything else is open. */
export function mapCaseStatus(input: { statusRaw: string | null; closedAt: Date | null }): 'open' | 'closed' {
  if (input.closedAt !== null) {
    return 'closed';
  }
  if (input.statusRaw !== null && CLOSED_CASE_STATUSES.includes(input.statusRaw)) {
    return 'closed';
  }
  return 'open';
}

const CHARGE_ADJUSTMENT_RECORD_TYPES: readonly string[] = [
  'Additional Tonnage',
  'Extended Rental',
  'Other Charges',
];

/**
 * Order_Line_Item__c RecordType.Name: these three record types are charge
 * adjustments on an existing placement, NOT new physical units — rendering
 * them as units would show phantom dumpsters.
 */
export function isChargeAdjustment(recordTypeName: string | null): boolean {
  return recordTypeName !== null && CHARGE_ADJUSTMENT_RECORD_TYPES.includes(recordTypeName);
}
```

- [ ] Run `npx vitest run tests/unit/status.test.ts` — expected PASS (39 tests: 7 order + 14 unit + 10 case + 8 charge-adjustment).
- [ ] Run `npx vitest run tests/unit` and `npx tsc --noEmit` — expected: all unit tests pass, no type errors.
- [ ] Commit:

```bash
git add lib/mapping/status.ts tests/unit/status.test.ts
git commit -m "feat: customer-safe status mapping for orders, units, and cases"
```
### Task 7: Row Mappers (SfRecord → Prisma upsert payloads)

**Files:**
- Create: `lib/sync/mappers.ts`
- Test: `tests/unit/mappers.test.ts`

**Interfaces:**

Consumes (from earlier tasks — see Interface Contracts in the plan header):
- `lib/salesforce/client.ts`: `export type SfRecord = Record<string, unknown> & { attributes?: unknown; Id: string }`
- `lib/mapping/status.ts`: `mapOrderStatus(raw: string | null): 'open' | 'closed'`, `mapUnitStatus(input: { lifecycleRaw: string | null; deliveryConfirmed: boolean; cancelReason: string | null }): UnitStatus`, `mapCaseStatus(input: { statusRaw: string | null; closedAt: Date | null }): 'open' | 'closed'`, `isChargeAdjustment(recordTypeName: string | null): boolean`
- `@prisma/client` generated types (Task 2 schema; requires `npx prisma generate` to have run) — no database connection needed for these unit tests.

Produces (relied on by Task 8):
```ts
export function mapAccount(r: SfRecord): Prisma.AccountUncheckedCreateInput
export function mapContact(r: SfRecord): Prisma.ContactUncheckedCreateInput
export function mapOrder(r: SfRecord): Prisma.OrderUncheckedCreateInput
export function mapOrderLineItem(r: SfRecord): Prisma.OrderLineItemUncheckedCreateInput
export function mapCase(r: SfRecord): Prisma.CaseRecordUncheckedCreateInput
export function mapTransaction(r: SfRecord): Prisma.TransactionUncheckedCreateInput
export function mapInvoice(r: SfRecord): Prisma.InvoiceUncheckedCreateInput
export function mapDeliveryConfirmation(r: SfRecord): Prisma.DeliveryConfirmationUncheckedCreateInput
export function mapSavedCard(r: SfRecord): Prisma.SavedCardUncheckedCreateInput
```

Cross-task dependency note (verify, do not silently work around): the mappers read `RecordType.DeveloperName` on Account, `RecordType.Name` on Order_Line_Item__c, and `Order__r.Account__c` on Transaction__c and Netsuite_Transactions__c — exactly the relationship fields Task 4's `ALLOWLIST` already selects (its leak test pins `selectClause('Account')` to `'Id, Name, RecordType.DeveloperName, IsDeleted, SystemModstamp'`). `Id`, `IsDeleted`, and `SystemModstamp` are likewise allowlisted on every object. If a mapper and the allowlist ever disagree, fix the **mapper** to read the allowlisted field — do not add new relationship fields to the allowlist, which would break Task 4's pinned leak test.

Steps:

- [ ] Write the failing unit test at `tests/unit/mappers.test.ts` with exactly this content:

```ts
import { describe, expect, it } from 'vitest';
import type { SfRecord } from '../../lib/salesforce/client';
import { mapCaseStatus, mapUnitStatus } from '../../lib/mapping/status';
import {
  mapAccount,
  mapContact,
  mapOrder,
  mapOrderLineItem,
  mapCase,
  mapTransaction,
  mapInvoice,
  mapDeliveryConfirmation,
  mapSavedCard,
} from '../../lib/sync/mappers';

const STAMP = '2026-07-10T14:30:00.000Z';

function attrs(type: string): { type: string; url: string } {
  return { type, url: `/services/data/v59.0/sobjects/${type}/x` };
}

describe('mapAccount', () => {
  it('maps a full record including the RecordType relationship, trimming strings', () => {
    const r: SfRecord = {
      attributes: attrs('Account'),
      Id: '001000000000001AAA',
      Name: '  Target Corp  ',
      RecordType: { attributes: attrs('RecordType'), DeveloperName: 'Business_Account' },
      IsDeleted: false,
      SystemModstamp: STAMP,
    };
    expect(mapAccount(r)).toEqual({
      id: '001000000000001AAA',
      name: 'Target Corp',
      recordType: 'Business_Account',
      isDeleted: false,
      sfModstamp: new Date(STAMP),
    });
  });

  it('tolerates a missing RecordType relationship row and flags deleted rows', () => {
    const r: SfRecord = {
      attributes: attrs('Account'),
      Id: '001000000000002AAA',
      Name: 'Acme',
      RecordType: null,
      IsDeleted: true,
      SystemModstamp: STAMP,
    };
    const out = mapAccount(r);
    expect(out.recordType).toBe('');
    expect(out.isDeleted).toBe(true);
  });
});

describe('mapContact', () => {
  it('maps fields and lowercases + trims the email', () => {
    const r: SfRecord = {
      attributes: attrs('Contact'),
      Id: '003000000000001AAA',
      AccountId: '001000000000001AAA',
      Email: '  Jane.Foreman@Example.COM ',
      FirstName: 'Jane',
      LastName: 'Foreman',
      Phone: '214-555-0100',
      IsPersonAccount: false,
      IsDeleted: false,
      SystemModstamp: STAMP,
    };
    const out = mapContact(r);
    expect(out.email).toBe('jane.foreman@example.com');
    expect(out.accountId).toBe('001000000000001AAA');
    expect(out.firstName).toBe('Jane');
    expect(out.lastName).toBe('Foreman');
    expect(out.phone).toBe('214-555-0100');
    expect(out.isPersonAccount).toBe(false);
  });

  it('handles person-account contacts with missing optional fields', () => {
    const r: SfRecord = {
      attributes: attrs('Contact'),
      Id: '003000000000002AAA',
      AccountId: '001000000000002AAA',
      Email: null,
      FirstName: null,
      LastName: 'Household',
      Phone: null,
      IsPersonAccount: true,
      IsDeleted: false,
      SystemModstamp: STAMP,
    };
    const out = mapContact(r);
    expect(out.email).toBeNull();
    expect(out.firstName).toBeNull();
    expect(out.isPersonAccount).toBe(true);
  });
});

describe('mapOrder', () => {
  const base: SfRecord = {
    attributes: attrs('Customer_Orders__c'),
    Id: 'a0B000000000001AAA',
    Name: 'CO-482910',
    Account__c: '001000000000001AAA',
    Company__c: 'LDR Site Services',
    Customer_Order_Status__c: 'Open',
    Shipping_Street__c: '500 Elm St',
    Shipping_City__c: 'Dallas',
    Shipping_State__c: 'TX',
    Shipping_Zip__c: '75201',
    Site_Address__c: ' 500 Elm St, Dallas, TX 75201 ',
    Store_Name__c: 'Target',
    Store_Number__c: 'T-1123',
    Location_Name_Other__c: null,
    Site_Contact__c: 'Jane Foreman',
    Site_Contact_Telephone__c: '214-555-0100',
    Setting_Instructions__c: 'Drop on left side of lot',
    Delivery_Date__c: '2026-07-03',
    First_OLI_Delivery_Date__c: '2026-07-03',
    Latest_OLI_End_Date__c: '2026-07-31',
    Order_Completed_Date__c: null,
    LIne_Total_Amount__c: 545,
    Tax_Amount_Oli__c: 44.96,
    Total_Amount_After_Tax__c: 589.96,
    Charged_Amount__c: 439.96,
    Credited_Amount__c: 0,
    NewBalance__c: 150,
    Customer_PO__c: 'PO-9911',
    Customer_Ref__c: null,
    Notes_to_Customer__c: 'Gate code 4411',
    IsDeleted: false,
    SystemModstamp: STAMP,
  };

  it('maps identity, site, money and date fields', () => {
    const out = mapOrder(base);
    expect(out.id).toBe('a0B000000000001AAA');
    expect(out.accountId).toBe('001000000000001AAA');
    expect(out.orderNumber).toBe('CO-482910');
    expect(out.company).toBe('LDR Site Services');
    expect(out.statusRaw).toBe('Open');
    expect(out.customerStatus).toBe('open');
    expect(out.shippingCity).toBe('Dallas');
    expect(out.siteAddress).toBe('500 Elm St, Dallas, TX 75201');
    expect(out.storeNumber).toBe('T-1123');
    expect(out.locationNameOther).toBeNull();
    expect(out.deliveryDate).toEqual(new Date('2026-07-03T00:00:00.000Z'));
    expect(out.latestEndDate).toEqual(new Date('2026-07-31T00:00:00.000Z'));
    expect(out.completedDate).toBeNull();
    expect(out.lineTotal).toBe('545');
    expect(out.taxAmount).toBe('44.96');
    expect(out.totalAfterTax).toBe('589.96');
    expect(out.chargedAmount).toBe('439.96');
    expect(out.creditedAmount).toBe('0');
    expect(out.balance).toBe('150');
    expect(out.customerPo).toBe('PO-9911');
    expect(out.customerRef).toBeNull();
    expect(out.sfModstamp).toEqual(new Date(STAMP));
  });

  it('maps any non-Open status (including null) to closed', () => {
    expect(mapOrder({ ...base, Customer_Order_Status__c: 'Audit' }).customerStatus).toBe('closed');
    expect(mapOrder({ ...base, Customer_Order_Status__c: 'Closed' }).customerStatus).toBe('closed');
    expect(mapOrder({ ...base, Customer_Order_Status__c: null }).customerStatus).toBe('closed');
  });
});

describe('mapOrderLineItem', () => {
  const placement: SfRecord = {
    attributes: attrs('Order_Line_Item__c'),
    Id: 'a0C000000000001AAA',
    Order__c: 'a0B000000000001AAA',
    RecordType: { attributes: attrs('RecordType'), Name: 'Dumpster' },
    Job_Type__c: 'Dumpster',
    Product__c: 'Roll-off',
    Size__c: '20 yd',
    Quantity__c: 2,
    Material__c: 'Construction Debris',
    Service__c: null,
    Service_Day__c: 'Monday',
    Service_Day_2__c: null,
    Service_Day_3__c: 'Friday',
    Service_Day_4__c: null,
    KAM_Deliveries_Removals__c: 'Delivery Executed',
    Delivery_Confirmed__c: true,
    Delivery_Confirmed_Date__c: '2026-07-03T15:02:11.000Z',
    Removal_Confirmed__c: false,
    Removal_Confirmed_Date__c: null,
    Delivery_Date__c: '2026-07-03',
    Expected_Delivery_Time__c: 'AM',
    End_Date__c: '2026-07-31',
    Projected_End_Date__c: '2026-07-28',
    Swap_Date__c: null,
    Rental_Days__c: 28,
    Price__c: 425,
    Delivery_Fee__c: 75,
    Tons_Included__c: 3,
    Price_Per_Ton__c: 65,
    Price_Per_Additional_Days__c: 10,
    Additional_Days__c: 0,
    Additional_Ton__c: 0,
    Additional_Fees__c: 0,
    Swap_Charge__c: null,
    LIne_Total_Amount__c: 500,
    Line_Tax_Amount__c: 41.25,
    Line_Total_Amount_After_Tax__c: 541.25,
    Amount_Charged__c: 541.25,
    Open_Balance__c: 0,
    Payment_Received__c: 541.25,
    Line_Notes_to_Customer__c: 'Placed at loading dock',
    If_cancelled_why__c: null,
    IsDeleted: false,
    SystemModstamp: STAMP,
  };

  it('maps a placement OLI with joined service days and derived unit status', () => {
    const out = mapOrderLineItem(placement);
    expect(out.orderId).toBe('a0B000000000001AAA');
    expect(out.recordType).toBe('Dumpster');
    expect(out.isChargeAdjustment).toBe(false);
    expect(out.jobType).toBe('Dumpster');
    expect(out.serviceDays).toBe('Monday, Friday');
    expect(out.quantity).toBe(2);
    expect(out.rentalDays).toBe(28);
    expect(out.deliveryConfirmed).toBe(true);
    expect(out.deliveryConfirmedDate).toEqual(new Date('2026-07-03T15:02:11.000Z'));
    expect(out.removalConfirmed).toBe(false);
    expect(out.deliveryDate).toEqual(new Date('2026-07-03T00:00:00.000Z'));
    expect(out.projectedEndDate).toEqual(new Date('2026-07-28T00:00:00.000Z'));
    expect(out.customerStatus).toBe(
      mapUnitStatus({ lifecycleRaw: 'Delivery Executed', deliveryConfirmed: true, cancelReason: null }),
    );
    expect(out.price).toBe('425');
    expect(out.pricePerAdditionalDay).toBe('10');
    expect(out.additionalTons).toBe('0');
    expect(out.lineTotal).toBe('500');
    expect(out.lineTax).toBe('41.25');
    expect(out.lineTotalAfterTax).toBe('541.25');
    expect(out.openBalance).toBe('0');
    expect(out.notesToCustomer).toBe('Placed at loading dock');
  });

  it('flags charge-adjustment record types and nulls empty service days', () => {
    const adj: SfRecord = {
      ...placement,
      Id: 'a0C000000000002AAA',
      RecordType: { attributes: attrs('RecordType'), Name: 'Additional Tonnage' },
      Service_Day__c: null,
      Service_Day_3__c: null,
    };
    const out = mapOrderLineItem(adj);
    expect(out.recordType).toBe('Additional Tonnage');
    expect(out.isChargeAdjustment).toBe(true);
    expect(out.serviceDays).toBeNull();
  });

  it('feeds the cancel reason into the unit status derivation', () => {
    const cancelled: SfRecord = {
      ...placement,
      Id: 'a0C000000000003AAA',
      KAM_Deliveries_Removals__c: null,
      Delivery_Confirmed__c: false,
      If_cancelled_why__c: 'Customer cancelled',
    };
    const out = mapOrderLineItem(cancelled);
    expect(out.cancelReason).toBe('Customer cancelled');
    expect(out.customerStatus).toBe(
      mapUnitStatus({ lifecycleRaw: null, deliveryConfirmed: false, cancelReason: 'Customer cancelled' }),
    );
  });

  it('tolerates a missing RecordType relationship row', () => {
    const bare: SfRecord = { ...placement, Id: 'a0C000000000004AAA', RecordType: null };
    const out = mapOrderLineItem(bare);
    expect(out.recordType).toBeNull();
    expect(out.isChargeAdjustment).toBe(false);
  });
});

describe('mapCase', () => {
  it('maps links, status, and dates', () => {
    const closedAt = '2026-07-05T18:00:00.000Z';
    const r: SfRecord = {
      attributes: attrs('Case'),
      Id: '500000000000001AAA',
      AccountId: '001000000000001AAA',
      CaseNumber: '00351122',
      Subject: 'Swap needed',
      Description: 'Please swap the 20yd',
      Type: 'Swap Dumpster',
      Status: 'Closed',
      Priority: 'Medium',
      Resolution__c: 'Swap completed 7/5',
      Update_s__c: 'Vendor confirmed',
      Origin: 'Customer Portal',
      Customer_Order__c: 'a0B000000000001AAA',
      Order_Line_Item__c: 'a0C000000000001AAA',
      CreatedDate: '2026-07-04T12:00:00.000Z',
      ClosedDate: closedAt,
      IsDeleted: false,
      SystemModstamp: STAMP,
    };
    const out = mapCase(r);
    expect(out.accountId).toBe('001000000000001AAA');
    expect(out.caseNumber).toBe('00351122');
    expect(out.subject).toBe('Swap needed');
    expect(out.type).toBe('Swap Dumpster');
    expect(out.statusRaw).toBe('Closed');
    expect(out.status).toBe(mapCaseStatus({ statusRaw: 'Closed', closedAt: new Date(closedAt) }));
    expect(out.priority).toBe('Medium');
    expect(out.resolution).toBe('Swap completed 7/5');
    expect(out.updates).toBe('Vendor confirmed');
    expect(out.origin).toBe('Customer Portal');
    expect(out.orderId).toBe('a0B000000000001AAA');
    expect(out.orderLineItemId).toBe('a0C000000000001AAA');
    expect(out.sfCreatedAt).toEqual(new Date('2026-07-04T12:00:00.000Z'));
    expect(out.closedAt).toEqual(new Date(closedAt));
  });

  it('handles open cases with null account and links', () => {
    const r: SfRecord = {
      attributes: attrs('Case'),
      Id: '500000000000002AAA',
      AccountId: null,
      CaseNumber: '00351123',
      Subject: null,
      Description: null,
      Type: null,
      Status: 'New',
      Priority: null,
      Resolution__c: null,
      Update_s__c: null,
      Origin: 'Phone',
      Customer_Order__c: null,
      Order_Line_Item__c: null,
      CreatedDate: '2026-07-06T09:00:00.000Z',
      ClosedDate: null,
      IsDeleted: false,
      SystemModstamp: STAMP,
    };
    const out = mapCase(r);
    expect(out.accountId).toBeNull();
    expect(out.orderId).toBeNull();
    expect(out.orderLineItemId).toBeNull();
    expect(out.status).toBe(mapCaseStatus({ statusRaw: 'New', closedAt: null }));
    expect(out.closedAt).toBeNull();
  });
});

describe('mapTransaction', () => {
  const base: SfRecord = {
    attributes: attrs('Transaction__c'),
    Id: 'a0T000000000001AAA',
    Name: 'T-100234',
    Type__c: 'Payments',
    Voided__c: false,
    Amount__c: 541.25,
    Pre_tax_Amount__c: 500,
    Tax__c: 41.25,
    Credit_Amount__c: null,
    Credit_Card_Last_4__c: '4242',
    Credit_Card_Type__c: 'Visa',
    Item_Description__c: '20yd rental',
    Payment_Reference__c: 'ref-8812',
    Order__c: 'a0B000000000001AAA',
    Order__r: { attributes: attrs('Customer_Orders__c'), Account__c: '001000000000001AAA' },
    Order_Line_Item__c: 'a0C000000000001AAA',
    CreatedDate: '2026-07-03T16:00:00.000Z',
    IsDeleted: false,
    SystemModstamp: STAMP,
  };

  it('denormalizes accountId from Order__r and counts a normal payment', () => {
    const out = mapTransaction(base);
    expect(out.name).toBe('T-100234');
    expect(out.type).toBe('Payments');
    expect(out.accountId).toBe('001000000000001AAA');
    expect(out.orderId).toBe('a0B000000000001AAA');
    expect(out.orderLineItemId).toBe('a0C000000000001AAA');
    expect(out.isVoided).toBe(false);
    expect(out.countsTowardTotals).toBe(true);
    expect(out.amount).toBe('541.25');
    expect(out.preTaxAmount).toBe('500');
    expect(out.tax).toBe('41.25');
    expect(out.creditAmount).toBeNull();
    expect(out.cardLast4).toBe('4242');
    expect(out.cardType).toBe('Visa');
    expect(out.sfCreatedAt).toEqual(new Date('2026-07-03T16:00:00.000Z'));
  });

  it('flags Void type rows as voided and excluded from totals', () => {
    const out = mapTransaction({ ...base, Type__c: 'Void' });
    expect(out.isVoided).toBe(true);
    expect(out.countsTowardTotals).toBe(false);
  });

  it('excludes Decline rows from totals without marking them voided', () => {
    const out = mapTransaction({ ...base, Type__c: 'Decline' });
    expect(out.isVoided).toBe(false);
    expect(out.countsTowardTotals).toBe(false);
  });

  it('flags Voided__c rows regardless of type', () => {
    const out = mapTransaction({ ...base, Voided__c: true });
    expect(out.isVoided).toBe(true);
    expect(out.countsTowardTotals).toBe(false);
  });

  it('leaves accountId null when the Order__r relationship row is missing', () => {
    const out = mapTransaction({ ...base, Order__r: null });
    expect(out.accountId).toBeNull();
  });
});

describe('mapInvoice', () => {
  it('maps totals, dates, and the relationship-derived account', () => {
    const r: SfRecord = {
      attributes: attrs('Netsuite_Transactions__c'),
      Id: 'a0N000000000001AAA',
      Name: 'INV-30021',
      Amount_Total__c: 589.96,
      Amount_Paid__c: 439.96,
      Amount_Due__c: 150,
      Tax_Amount__c: 44.96,
      Transaction_Date__c: '2026-07-04',
      Transaction_Due_date__c: '2026-08-03',
      Status__c: 'Open',
      Type__c: 'Invoice',
      Payment_Method__c: 'Net Terms',
      Memo__c: 'July service',
      Order__c: 'a0B000000000001AAA',
      Order__r: { attributes: attrs('Customer_Orders__c'), Account__c: '001000000000001AAA' },
      IsDeleted: false,
      SystemModstamp: STAMP,
    };
    const out = mapInvoice(r);
    expect(out.name).toBe('INV-30021');
    expect(out.accountId).toBe('001000000000001AAA');
    expect(out.orderId).toBe('a0B000000000001AAA');
    expect(out.amountTotal).toBe('589.96');
    expect(out.amountPaid).toBe('439.96');
    expect(out.amountDue).toBe('150');
    expect(out.taxAmount).toBe('44.96');
    expect(out.transactionDate).toEqual(new Date('2026-07-04T00:00:00.000Z'));
    expect(out.dueDate).toEqual(new Date('2026-08-03T00:00:00.000Z'));
    expect(out.status).toBe('Open');
    expect(out.type).toBe('Invoice');
    expect(out.paymentMethod).toBe('Net Terms');
    expect(out.memo).toBe('July service');
  });

  it('leaves accountId null when Order__r is absent', () => {
    const r: SfRecord = {
      attributes: attrs('Netsuite_Transactions__c'),
      Id: 'a0N000000000002AAA',
      Name: 'INV-30022',
      Amount_Total__c: null,
      Amount_Paid__c: null,
      Amount_Due__c: null,
      Tax_Amount__c: null,
      Transaction_Date__c: null,
      Transaction_Due_date__c: null,
      Status__c: null,
      Type__c: 'Invoice',
      Payment_Method__c: null,
      Memo__c: null,
      Order__c: null,
      IsDeleted: false,
      SystemModstamp: STAMP,
    };
    const out = mapInvoice(r);
    expect(out.accountId).toBeNull();
    expect(out.orderId).toBeNull();
    expect(out.amountTotal).toBeNull();
    expect(out.transactionDate).toBeNull();
  });
});

describe('mapDeliveryConfirmation', () => {
  it('maps the OLI link from Name__c', () => {
    const r: SfRecord = {
      attributes: attrs('Vendor_Delivery_Notification__c'),
      Id: 'a0V000000000001AAA',
      Order__c: 'a0B000000000001AAA',
      Name__c: 'a0C000000000001AAA',
      Is_Responded__c: true,
      Responded_on__c: '2026-07-03T15:05:00.000Z',
      Response__c: 'yes',
      IsDeleted: false,
      SystemModstamp: STAMP,
    };
    const out = mapDeliveryConfirmation(r);
    expect(out.orderId).toBe('a0B000000000001AAA');
    expect(out.orderLineItemId).toBe('a0C000000000001AAA');
    expect(out.isResponded).toBe(true);
    expect(out.respondedOn).toEqual(new Date('2026-07-03T15:05:00.000Z'));
    expect(out.response).toBe('yes');
  });

  it('handles unanswered notifications', () => {
    const r: SfRecord = {
      attributes: attrs('Vendor_Delivery_Notification__c'),
      Id: 'a0V000000000002AAA',
      Order__c: null,
      Name__c: null,
      Is_Responded__c: false,
      Responded_on__c: null,
      Response__c: null,
      IsDeleted: false,
      SystemModstamp: STAMP,
    };
    const out = mapDeliveryConfirmation(r);
    expect(out.orderId).toBeNull();
    expect(out.orderLineItemId).toBeNull();
    expect(out.isResponded).toBe(false);
    expect(out.respondedOn).toBeNull();
    expect(out.response).toBeNull();
  });
});

describe('mapSavedCard', () => {
  const base: SfRecord = {
    attributes: attrs('Credit_Card__c'),
    Id: 'a0K000000000001AAA',
    Account__c: '001000000000001AAA',
    Company__c: 'LDR Site Services',
    Name: 'Corporate Visa',
    Card_Number_Last_4__c: '4242',
    Credit_Card_Type__c: 'Visa',
    Expiration_Date__c: '12/2026',
    Primary__c: true,
    IsDeleted: false,
    SystemModstamp: STAMP,
  };

  it('maps display fields with Name as the label', () => {
    const out = mapSavedCard(base);
    expect(out.accountId).toBe('001000000000001AAA');
    expect(out.company).toBe('LDR Site Services');
    expect(out.label).toBe('Corporate Visa');
    expect(out.last4).toBe('4242');
    expect(out.cardType).toBe('Visa');
    expect(out.expiryDisplay).toBe('12/2026');
    expect(out.isPrimary).toBe(true);
  });

  it('stringifies non-string expiry values and nulls missing ones', () => {
    expect(mapSavedCard({ ...base, Expiration_Date__c: 1226 }).expiryDisplay).toBe('1226');
    expect(mapSavedCard({ ...base, Expiration_Date__c: null }).expiryDisplay).toBeNull();
    expect(mapSavedCard({ ...base, Expiration_Date__c: undefined }).expiryDisplay).toBeNull();
  });
});
```

- [ ] Run the test and confirm it fails for the right reason:

```
npx vitest run tests/unit/mappers.test.ts
```

Expected failure: `Failed to load ...` / `Cannot find module '.../lib/sync/mappers'` (the module does not exist yet). If it fails instead on `lib/mapping/status` or `lib/salesforce/client`, stop — those earlier tasks are incomplete.

- [ ] Create `lib/sync/mappers.ts` with exactly this content:

```ts
import type { Prisma } from '@prisma/client';
import type { SfRecord } from '../salesforce/client';
import { isChargeAdjustment, mapCaseStatus, mapOrderStatus, mapUnitStatus } from '../mapping/status';

// One mapper per synced object: SfRecord (raw Salesforce JSON) → Prisma
// *UncheckedCreateInput used for upserts. All Decimal columns are passed as
// strings; all optional strings are trimmed-or-null; SF date-only fields are
// parsed as UTC midnight so @db.Date columns round-trip deterministically.

// ── shared private helpers ──────────────────────────────────────────────────

/** Trimmed string or null (null/undefined/empty/non-string → null). */
function s(v: unknown): string | null {
  if (typeof v !== 'string') return null;
  const t = v.trim();
  return t === '' ? null : t;
}

/** Prisma-compatible decimal as a string, or null. */
function n(v: unknown): string | null {
  if (typeof v === 'number' && Number.isFinite(v)) return v.toString();
  if (typeof v === 'string') {
    const t = v.trim();
    if (t !== '' && Number.isFinite(Number(t))) return t;
  }
  return null;
}

/** Integer (for Prisma Int columns) or null. */
function i(v: unknown): number | null {
  const parsed = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : Number.NaN;
  return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
}

/** Boolean coercion — SF JSON booleans are real booleans; tolerate 'true'. */
function b(v: unknown): boolean {
  return v === true || v === 'true';
}

/** Datetime or null. */
function d(v: unknown): Date | null {
  if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v;
  if (typeof v !== 'string' || v.trim() === '') return null;
  const parsed = new Date(v);
  return Number.isNaN(parsed.getTime()) ? null : parsed;
}

const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;

/** SF date-only field ('YYYY-MM-DD') → Date at UTC midnight, or null. */
function dateOnly(v: unknown): Date | null {
  if (typeof v === 'string' && DATE_ONLY.test(v.trim())) return new Date(`${v.trim()}T00:00:00.000Z`);
  return d(v);
}

/** Nested relationship access, e.g. rel(r, 'Order__r', 'Account__c'). */
function rel(r: SfRecord, ...path: string[]): unknown {
  let current: unknown = r;
  for (const key of path) {
    if (current === null || current === undefined || typeof current !== 'object') return null;
    current = (current as Record<string, unknown>)[key];
  }
  return current ?? null;
}

/** SystemModstamp is required on every mirror row. */
function modstamp(r: SfRecord): Date {
  return d(r.SystemModstamp) ?? new Date(0);
}

// ── mappers ─────────────────────────────────────────────────────────────────

export function mapAccount(r: SfRecord): Prisma.AccountUncheckedCreateInput {
  return {
    id: r.Id,
    name: s(r.Name) ?? '',
    recordType: s(rel(r, 'RecordType', 'DeveloperName')) ?? '',
    isDeleted: b(r.IsDeleted),
    sfModstamp: modstamp(r),
  };
}

export function mapContact(r: SfRecord): Prisma.ContactUncheckedCreateInput {
  const email = s(r.Email);
  return {
    id: r.Id,
    accountId: s(r.AccountId) ?? '',
    // Lowercased at ingest so magic-link lookups can match case-insensitively.
    email: email === null ? null : email.toLowerCase(),
    firstName: s(r.FirstName),
    lastName: s(r.LastName),
    phone: s(r.Phone),
    isPersonAccount: b(r.IsPersonAccount),
    isDeleted: b(r.IsDeleted),
    sfModstamp: modstamp(r),
  };
}

export function mapOrder(r: SfRecord): Prisma.OrderUncheckedCreateInput {
  const statusRaw = s(r.Customer_Order_Status__c);
  return {
    id: r.Id,
    accountId: s(r.Account__c) ?? '',
    orderNumber: s(r.Name) ?? '',
    company: s(r.Company__c),
    statusRaw,
    customerStatus: mapOrderStatus(statusRaw),
    shippingStreet: s(r.Shipping_Street__c),
    shippingCity: s(r.Shipping_City__c),
    shippingState: s(r.Shipping_State__c),
    shippingZip: s(r.Shipping_Zip__c),
    siteAddress: s(r.Site_Address__c),
    storeName: s(r.Store_Name__c),
    storeNumber: s(r.Store_Number__c),
    locationNameOther: s(r.Location_Name_Other__c),
    siteContact: s(r.Site_Contact__c),
    siteContactPhone: s(r.Site_Contact_Telephone__c),
    settingInstructions: s(r.Setting_Instructions__c),
    deliveryDate: dateOnly(r.Delivery_Date__c),
    firstDeliveryDate: dateOnly(r.First_OLI_Delivery_Date__c),
    latestEndDate: dateOnly(r.Latest_OLI_End_Date__c),
    completedDate: dateOnly(r.Order_Completed_Date__c),
    lineTotal: n(r.LIne_Total_Amount__c),
    taxAmount: n(r.Tax_Amount_Oli__c),
    totalAfterTax: n(r.Total_Amount_After_Tax__c),
    chargedAmount: n(r.Charged_Amount__c),
    creditedAmount: n(r.Credited_Amount__c),
    balance: n(r.NewBalance__c),
    customerPo: s(r.Customer_PO__c),
    customerRef: s(r.Customer_Ref__c),
    notesToCustomer: s(r.Notes_to_Customer__c),
    isDeleted: b(r.IsDeleted),
    sfModstamp: modstamp(r),
  };
}

export function mapOrderLineItem(r: SfRecord): Prisma.OrderLineItemUncheckedCreateInput {
  const recordType = s(rel(r, 'RecordType', 'Name'));
  const lifecycleRaw = s(r.KAM_Deliveries_Removals__c);
  const deliveryConfirmed = b(r.Delivery_Confirmed__c);
  const cancelReason = s(r.If_cancelled_why__c);
  const serviceDays = [r.Service_Day__c, r.Service_Day_2__c, r.Service_Day_3__c, r.Service_Day_4__c]
    .map((v) => s(v))
    .filter((v): v is string => v !== null)
    .join(', ');
  return {
    id: r.Id,
    orderId: s(r.Order__c) ?? '',
    recordType,
    isChargeAdjustment: isChargeAdjustment(recordType),
    jobType: s(r.Job_Type__c),
    product: s(r.Product__c),
    size: s(r.Size__c),
    quantity: i(r.Quantity__c),
    material: s(r.Material__c),
    service: s(r.Service__c),
    serviceDays: serviceDays === '' ? null : serviceDays,
    lifecycleRaw,
    customerStatus: mapUnitStatus({ lifecycleRaw, deliveryConfirmed, cancelReason }),
    deliveryConfirmed,
    deliveryConfirmedDate: d(r.Delivery_Confirmed_Date__c),
    removalConfirmed: b(r.Removal_Confirmed__c),
    removalConfirmedDate: d(r.Removal_Confirmed_Date__c),
    deliveryDate: dateOnly(r.Delivery_Date__c),
    expectedDeliveryTime: s(r.Expected_Delivery_Time__c),
    endDate: dateOnly(r.End_Date__c),
    projectedEndDate: dateOnly(r.Projected_End_Date__c),
    swapDate: dateOnly(r.Swap_Date__c),
    rentalDays: i(r.Rental_Days__c),
    price: n(r.Price__c),
    deliveryFee: n(r.Delivery_Fee__c),
    tonsIncluded: n(r.Tons_Included__c),
    pricePerTon: n(r.Price_Per_Ton__c),
    pricePerAdditionalDay: n(r.Price_Per_Additional_Days__c),
    additionalDays: n(r.Additional_Days__c),
    additionalTons: n(r.Additional_Ton__c),
    additionalFees: n(r.Additional_Fees__c),
    swapCharge: n(r.Swap_Charge__c),
    lineTotal: n(r.LIne_Total_Amount__c),
    lineTax: n(r.Line_Tax_Amount__c),
    lineTotalAfterTax: n(r.Line_Total_Amount_After_Tax__c),
    amountCharged: n(r.Amount_Charged__c),
    openBalance: n(r.Open_Balance__c),
    paymentReceived: n(r.Payment_Received__c),
    notesToCustomer: s(r.Line_Notes_to_Customer__c),
    cancelReason,
    isDeleted: b(r.IsDeleted),
    sfModstamp: modstamp(r),
  };
}

export function mapCase(r: SfRecord): Prisma.CaseRecordUncheckedCreateInput {
  const statusRaw = s(r.Status);
  const closedAt = d(r.ClosedDate);
  return {
    id: r.Id,
    accountId: s(r.AccountId),
    caseNumber: s(r.CaseNumber) ?? '',
    subject: s(r.Subject),
    description: s(r.Description),
    type: s(r.Type),
    statusRaw,
    status: mapCaseStatus({ statusRaw, closedAt }),
    priority: s(r.Priority),
    resolution: s(r.Resolution__c),
    updates: s(r.Update_s__c),
    origin: s(r.Origin),
    orderId: s(r.Customer_Order__c),
    orderLineItemId: s(r.Order_Line_Item__c),
    sfCreatedAt: d(r.CreatedDate) ?? new Date(0),
    closedAt,
    isDeleted: b(r.IsDeleted),
    sfModstamp: modstamp(r),
  };
}

export function mapTransaction(r: SfRecord): Prisma.TransactionUncheckedCreateInput {
  const type = s(r.Type__c);
  const isVoided = b(r.Voided__c) || type === 'Void';
  return {
    id: r.Id,
    name: s(r.Name) ?? '',
    type,
    isVoided,
    countsTowardTotals: !(isVoided || type === 'Decline' || type === 'Void'),
    amount: n(r.Amount__c),
    preTaxAmount: n(r.Pre_tax_Amount__c),
    tax: n(r.Tax__c),
    creditAmount: n(r.Credit_Amount__c),
    cardLast4: s(r.Credit_Card_Last_4__c),
    cardType: s(r.Credit_Card_Type__c),
    itemDescription: s(r.Item_Description__c),
    paymentReference: s(r.Payment_Reference__c),
    orderId: s(r.Order__c),
    accountId: s(rel(r, 'Order__r', 'Account__c')),
    orderLineItemId: s(r.Order_Line_Item__c),
    sfCreatedAt: d(r.CreatedDate) ?? new Date(0),
    isDeleted: b(r.IsDeleted),
    sfModstamp: modstamp(r),
  };
}

export function mapInvoice(r: SfRecord): Prisma.InvoiceUncheckedCreateInput {
  return {
    id: r.Id,
    name: s(r.Name) ?? '',
    amountTotal: n(r.Amount_Total__c),
    amountPaid: n(r.Amount_Paid__c),
    amountDue: n(r.Amount_Due__c),
    taxAmount: n(r.Tax_Amount__c),
    transactionDate: dateOnly(r.Transaction_Date__c),
    dueDate: dateOnly(r.Transaction_Due_date__c),
    status: s(r.Status__c),
    type: s(r.Type__c),
    paymentMethod: s(r.Payment_Method__c),
    memo: s(r.Memo__c),
    orderId: s(r.Order__c),
    accountId: s(rel(r, 'Order__r', 'Account__c')),
    isDeleted: b(r.IsDeleted),
    sfModstamp: modstamp(r),
  };
}

export function mapDeliveryConfirmation(r: SfRecord): Prisma.DeliveryConfirmationUncheckedCreateInput {
  return {
    id: r.Id,
    orderId: s(r.Order__c),
    orderLineItemId: s(r.Name__c),
    isResponded: b(r.Is_Responded__c),
    respondedOn: d(r.Responded_on__c),
    response: s(r.Response__c),
    isDeleted: b(r.IsDeleted),
    sfModstamp: modstamp(r),
  };
}

export function mapSavedCard(r: SfRecord): Prisma.SavedCardUncheckedCreateInput {
  return {
    id: r.Id,
    accountId: s(r.Account__c) ?? '',
    company: s(r.Company__c),
    label: s(r.Name) ?? '',
    last4: s(r.Card_Number_Last_4__c),
    cardType: s(r.Credit_Card_Type__c),
    expiryDisplay:
      r.Expiration_Date__c === null || r.Expiration_Date__c === undefined ? null : String(r.Expiration_Date__c),
    isPrimary: b(r.Primary__c),
    isDeleted: b(r.IsDeleted),
    sfModstamp: modstamp(r),
  };
}
```

- [ ] Run the test again and confirm it passes:

```
npx vitest run tests/unit/mappers.test.ts
```

Expected: all tests PASS.

- [ ] Typecheck (vitest does not check types):

```
npx tsc --noEmit
```

Expected: zero errors.

- [ ] Run the whole unit suite to confirm nothing regressed:

```
npx vitest run tests/unit
```

- [ ] Commit:

```
git add lib/sync/mappers.ts tests/unit/mappers.test.ts
git commit -m "feat: map Salesforce rows to Prisma upsert payloads for all nine synced objects"
```
### Task 8: Incremental sync engine + sync job entry

**Files:**
- Create: `lib/sync/runSync.ts`
- Create: `jobs/sync.ts`
- Modify: `lib/salesforce/mock.ts` (honor a trailing `LIMIT n` — required by `maxRows`/backfill)
- Modify: `tests/unit/sf-mock.test.ts` (add the LIMIT test)
- Test: `tests/integration/sync.test.ts`

**Interfaces:**

Consumes (all from earlier tasks — exact signatures in the plan header):
- `lib/db.ts`: `prisma: PrismaClient` (Task 3) — including model delegates `prisma.account`, `prisma.contact`, `prisma.order`, `prisma.orderLineItem`, `prisma.caseRecord`, `prisma.transaction`, `prisma.invoice`, `prisma.deliveryConfirmation`, `prisma.savedCard`, `prisma.syncState`, `prisma.syncRun` (Task 2 schema).
- `lib/logger.ts`: `logger` (Task 3).
- `lib/salesforce/soql.ts`: `soql` tagged template (Task 4).
- `lib/sync/fieldAllowlist.ts`: `SYNC_OBJECTS`, `type SyncObject`, `selectClause(obj)`, `whereClause(obj)` (Task 4).
- `lib/salesforce/client.ts`: `getSfClient(): Promise<SfClient>`, `type SfRecord` (Task 5). `SF_MODE` unset/`mock` → the mock client; integration tests seed via `mockSf.addRecords()` / `mockSf.reset()` from `lib/salesforce/mock.ts` (Task 5).
- `lib/sync/mappers.ts`: all nine mappers `mapAccount` … `mapSavedCard` (Task 7).

Produces (Task 9 backfill, Task on dashboard staleness banner, and the ops task rely on these exactly):

```ts
// lib/sync/runSync.ts
export interface SyncResult { runId: string; status: 'success' | 'failed' | 'skipped_locked'; objects: Record<string, { upserted: number; error?: string }> }
export async function runIncrementalSync(objects?: SyncObject[], opts?: { maxRows?: number }): Promise<SyncResult>
export async function getLastSuccessfulSyncAt(): Promise<Date | null>

// jobs/sync.ts
export const handler: () => Promise<SyncResult>   // Lambda entry (EventBridge Scheduler, every 2 min)
```

**Notes (design decisions — do not "fix"):**
- The plan-header contract lists `runIncrementalSync(objects?: SyncObject[])`. This task adds an **optional second parameter** `opts?: { maxRows?: number }` for backfill batching (Task 9). Every single-argument call site in the header contract remains valid — this is a compatible extension, prescribed by the task breakdown.
- **Advisory lock:** acquired with `prisma.$queryRaw` + `pg_try_advisory_lock(hashtext('streamlink_sync'))`, released in `finally` with `pg_advisory_unlock`. pg advisory locks are **session-scoped**, and Prisma pools connections — acquire and release must land on the same connection. The integration tests (and the deployed sync job) therefore run with `connection_limit=1` on `DATABASE_URL`. The Lambda is a short-lived single-invocation process, so even a missed unlock dies with the connection; `connection_limit=1` keeps it deterministic. If the lock is not acquired: return `{ runId: '', status: 'skipped_locked', objects: {} }` and write **no** SyncRun row.
- **FK ordering:** the mirror tables have real foreign keys (`contacts.account_id` → `accounts`, `orders.account_id` → `accounts`, `order_line_items.order_id` → `orders`). `SYNC_OBJECTS` is already parent-before-child, so a default full run never violates them. A child row arriving before its parent (possible in a real delta window, or in a targeted run) fails that object's sync; the per-object try/catch records it and the next run retries — upserts make retries harmless.
- **High-water mark:** advanced to the max `SystemModstamp` seen, only when rows > 0, and only after all chunks committed. The floor comparison is strict (`>`), so rows sharing the exact max modstamp at a `maxRows` batch boundary would be skipped — SF modstamps are millisecond-precision, acceptable, documented in code.
- Task 5's mock deliberately supports only the query shapes the sync worker emits. This task makes the sync worker emit `LIMIT n`, so this task extends the mock (and its unit test) to honor a trailing `LIMIT` — that is in-contract maintenance of the mock, not scope creep.
- Static SOQL fragments (`selectClause(obj)`, `whereClause(obj)`, object name, `ORDER BY`) are compile-time constants generated from the allowlist and contain no user input; only the dynamic values (high-water mark `Date`, `maxRows`) are interpolated, and both go through the `soql` tag — this satisfies the Global Constraint.
- Integration fixtures give the Account's `RecordType` as `{ DeveloperName: '…' }` and the OLI's as `{ Name: '…' }` because Task 7's `mapAccount` reads `RecordType.DeveloperName` while `mapOrderLineItem` reads `RecordType.Name` — matching each object's allowlist (the mock ignores SELECT clauses entirely, so fixtures must match the **mapper's** field expectations).

**Steps:**

- [ ] Precondition: local database up with migrations applied (Task 2). From the repo root:

```bash
npm run db:up
npx prisma migrate deploy
```

Expected: `db:up` exits 0 with the `db` service healthy on port 5433; `migrate deploy` reports all migrations applied (it reads `DATABASE_URL` from `.env`).

- [ ] Write the failing integration test. Create `tests/integration/sync.test.ts` with exactly this content:

```ts
// Integration test: requires the Task 2 docker Postgres (npm run db:up) with
// migrations applied. Runs the sync engine end-to-end against the mock
// Salesforce client (SF_MODE=mock — no network).
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import type { SfRecord } from '../../lib/salesforce/client';

// ── env BEFORE lib/db loads (hence the dynamic imports below) ───────────────
// connection_limit=1 pins the Prisma pool to a single connection so the pg
// SESSION-scoped advisory lock is acquired and released on the same session.
process.env.SF_MODE = 'mock';
process.env.DATABASE_URL ??= 'postgresql://streamlink:streamlink@localhost:5433/streamlink';
if (!process.env.DATABASE_URL.includes('connection_limit=')) {
  process.env.DATABASE_URL += process.env.DATABASE_URL.includes('?')
    ? '&connection_limit=1'
    : '?connection_limit=1';
}

const { prisma } = await import('../../lib/db');
const { mockSf } = await import('../../lib/salesforce/mock');
const { getLastSuccessfulSyncAt, runIncrementalSync } = await import('../../lib/sync/runSync');
const { handler } = await import('../../jobs/sync');

const T0 = '2026-07-15T10:00:00.000Z';
const T1 = '2026-07-15T11:00:00.000Z';
const T2 = '2026-07-16T09:00:00.000Z';

function attrs(type: string): { type: string; url: string } {
  return { type, url: `/services/data/v59.0/sobjects/${type}/x` };
}

function accountRow(overrides: Partial<SfRecord> = {}): SfRecord {
  return {
    attributes: attrs('Account'),
    Id: '001000000000001AAA',
    Name: 'Target Corp',
    RecordType: { attributes: attrs('RecordType'), DeveloperName: 'Business_Account' },
    IsDeleted: false,
    SystemModstamp: T0,
    ...overrides,
  };
}

function contactRow(overrides: Partial<SfRecord> = {}): SfRecord {
  return {
    attributes: attrs('Contact'),
    Id: '003000000000001AAA',
    AccountId: '001000000000001AAA',
    Email: ' Jane.Foreman@Example.COM ',
    FirstName: 'Jane',
    LastName: 'Foreman',
    Phone: '214-555-0100',
    IsPersonAccount: false,
    IsDeleted: false,
    SystemModstamp: T0,
    ...overrides,
  };
}

function orderRow(overrides: Partial<SfRecord> = {}): SfRecord {
  return {
    attributes: attrs('Customer_Orders__c'),
    Id: 'a0B000000000001AAA',
    Name: 'CO-482910',
    Account__c: '001000000000001AAA',
    Company__c: 'LDR Site Services',
    Customer_Order_Status__c: 'Open',
    Shipping_Street__c: '500 Elm St',
    Shipping_City__c: 'Dallas',
    Shipping_State__c: 'TX',
    Shipping_Zip__c: '75201',
    Site_Address__c: '500 Elm St, Dallas, TX 75201',
    Store_Name__c: 'Target',
    Store_Number__c: 'T-1123',
    Delivery_Date__c: '2026-07-03',
    LIne_Total_Amount__c: 545,
    Tax_Amount_Oli__c: 44.96,
    Total_Amount_After_Tax__c: 589.96,
    Charged_Amount__c: 439.96,
    NewBalance__c: 150,
    IsDeleted: false,
    SystemModstamp: T0,
    ...overrides,
  };
}

function oliRow(overrides: Partial<SfRecord> = {}): SfRecord {
  return {
    attributes: attrs('Order_Line_Item__c'),
    Id: 'a0C000000000001AAA',
    Order__c: 'a0B000000000001AAA',
    RecordType: { attributes: attrs('RecordType'), Name: 'Dumpster' },
    Job_Type__c: 'Dumpster',
    Product__c: 'Roll-off',
    Size__c: '20 yd',
    Quantity__c: 1,
    KAM_Deliveries_Removals__c: 'Delivery Executed',
    Delivery_Confirmed__c: true,
    Delivery_Date__c: '2026-07-03',
    End_Date__c: '2026-07-31',
    Price__c: 425,
    LIne_Total_Amount__c: 500,
    IsDeleted: false,
    SystemModstamp: T0,
    ...overrides,
  };
}

async function resetSyncTables(): Promise<void> {
  await prisma.$executeRaw`TRUNCATE TABLE order_line_items, orders, contacts, accounts, cases, transactions, invoices, delivery_confirmations, saved_cards, sync_state, sync_runs CASCADE`;
}

beforeEach(async () => {
  mockSf.reset();
  await resetSyncTables();
});

afterAll(async () => {
  await prisma.$disconnect();
});

describe('runIncrementalSync', () => {
  it('upserts seeded rows across Account, Contact, Customer_Orders__c and Order_Line_Item__c', async () => {
    mockSf.addRecords('Account', [accountRow()]);
    mockSf.addRecords('Contact', [contactRow()]);
    mockSf.addRecords('Customer_Orders__c', [orderRow()]);
    mockSf.addRecords('Order_Line_Item__c', [oliRow()]);

    const res = await runIncrementalSync([
      'Account',
      'Contact',
      'Customer_Orders__c',
      'Order_Line_Item__c',
    ]);

    expect(res.status).toBe('success');
    expect(res.runId).not.toBe('');
    expect(res.objects).toEqual({
      Account: { upserted: 1 },
      Contact: { upserted: 1 },
      Customer_Orders__c: { upserted: 1 },
      Order_Line_Item__c: { upserted: 1 },
    });

    const account = await prisma.account.findUniqueOrThrow({ where: { id: '001000000000001AAA' } });
    expect(account.name).toBe('Target Corp');
    expect(account.recordType).toBe('Business_Account');
    expect(account.sfModstamp.toISOString()).toBe(T0);

    const contact = await prisma.contact.findUniqueOrThrow({ where: { id: '003000000000001AAA' } });
    expect(contact.email).toBe('jane.foreman@example.com'); // trimmed + lowercased at ingest
    expect(contact.accountId).toBe('001000000000001AAA');

    const order = await prisma.order.findUniqueOrThrow({ where: { id: 'a0B000000000001AAA' } });
    expect(order.customerStatus).toBe('open');
    expect(order.balance?.toFixed(2)).toBe('150.00');
    expect(order.deliveryDate).toEqual(new Date('2026-07-03T00:00:00.000Z'));

    const oli = await prisma.orderLineItem.findUniqueOrThrow({ where: { id: 'a0C000000000001AAA' } });
    expect(oli.orderId).toBe('a0B000000000001AAA');
    expect(oli.customerStatus).toBe('delivered');
    expect(oli.isChargeAdjustment).toBe(false);

    const states = await prisma.syncState.findMany();
    expect(states).toHaveLength(4);
    for (const state of states) {
      expect(state.highWaterMark.toISOString()).toBe(T0);
    }

    const run = await prisma.syncRun.findUniqueOrThrow({ where: { id: res.runId } });
    expect(run.status).toBe('success');
    expect(run.finishedAt).not.toBeNull();
    expect(run.error).toBeNull();

    const lastSync = await getLastSuccessfulSyncAt();
    expect(lastSync?.getTime()).toBe(run.finishedAt?.getTime());
  });

  it('second run picks up only rows newer than the high-water mark and advances it', async () => {
    mockSf.addRecords('Account', [accountRow()]);
    mockSf.addRecords('Contact', [contactRow()]);
    const first = await runIncrementalSync(['Account', 'Contact']);
    expect(first.status).toBe('success');

    // Same Account Id with a newer modstamp and a changed name; Contact unchanged.
    mockSf.addRecords('Account', [accountRow({ Name: 'Target Corporation', SystemModstamp: T2 })]);

    const second = await runIncrementalSync(['Account', 'Contact']);
    expect(second.status).toBe('success');
    expect(second.objects).toEqual({ Account: { upserted: 1 }, Contact: { upserted: 0 } });

    const account = await prisma.account.findUniqueOrThrow({ where: { id: '001000000000001AAA' } });
    expect(account.name).toBe('Target Corporation');

    const accountState = await prisma.syncState.findUniqueOrThrow({ where: { object: 'Account' } });
    expect(accountState.highWaterMark.toISOString()).toBe(T2);
    const contactState = await prisma.syncState.findUniqueOrThrow({ where: { object: 'Contact' } });
    expect(contactState.highWaterMark.toISOString()).toBe(T0); // untouched — zero newer rows
  });

  it('caps a run at maxRows and leaves the high-water mark on the last ingested row', async () => {
    mockSf.addRecords('Account', [
      accountRow(),
      accountRow({ Id: '001000000000002AAA', Name: 'Acme', SystemModstamp: T1 }),
      accountRow({ Id: '001000000000003AAA', Name: 'Globex', SystemModstamp: T2 }),
    ]);

    const res = await runIncrementalSync(['Account'], { maxRows: 2 });
    expect(res.status).toBe('success');
    expect(res.objects).toEqual({ Account: { upserted: 2 } });
    expect(await prisma.account.count()).toBe(2);
    const state = await prisma.syncState.findUniqueOrThrow({ where: { object: 'Account' } });
    expect(state.highWaterMark.toISOString()).toBe(T1);

    const rest = await runIncrementalSync(['Account'], { maxRows: 2 });
    expect(rest.objects).toEqual({ Account: { upserted: 1 } });
    expect(await prisma.account.count()).toBe(3);
  });

  it('isolates a per-object failure: other objects sync, run is failed, hwm not advanced for the failed object', async () => {
    mockSf.addRecords('Account', [accountRow()]);
    // Malformed fixture technique: this order references an account that was
    // never synced, so the orders.account_id foreign key rejects the upsert —
    // a real DB-level failure isolated to Customer_Orders__c.
    mockSf.addRecords('Customer_Orders__c', [orderRow({ Account__c: '001MISSING00001AAA' })]);

    const res = await runIncrementalSync(['Account', 'Customer_Orders__c']);

    expect(res.status).toBe('failed');
    expect(res.objects.Account).toEqual({ upserted: 1 });
    expect(res.objects.Customer_Orders__c.upserted).toBe(0);
    expect(res.objects.Customer_Orders__c.error).toMatch(/foreign key/i);

    expect(await prisma.account.count()).toBe(1); // healthy object landed
    expect(await prisma.order.count()).toBe(0);

    expect(await prisma.syncState.findUnique({ where: { object: 'Customer_Orders__c' } })).toBeNull();
    const accountState = await prisma.syncState.findUniqueOrThrow({ where: { object: 'Account' } });
    expect(accountState.highWaterMark.toISOString()).toBe(T0);

    const run = await prisma.syncRun.findUniqueOrThrow({ where: { id: res.runId } });
    expect(run.status).toBe('failed');
    expect(run.error).toContain('Customer_Orders__c');
  });

  it('returns skipped_locked without creating a SyncRun when the advisory lock is held elsewhere', async () => {
    const url = process.env.DATABASE_URL;
    if (url === undefined) throw new Error('DATABASE_URL not set');
    // Second client = second pool = separate pg session holding the lock.
    // (The 'pg' package is not installed; a raw PrismaClient does the job.)
    const locker = new PrismaClient({ datasourceUrl: url });
    try {
      const acquired = await locker.$queryRaw<
        { locked: boolean }[]
      >`SELECT pg_try_advisory_lock(hashtext('streamlink_sync')) AS locked`;
      expect(acquired[0].locked).toBe(true);

      mockSf.addRecords('Account', [accountRow()]);
      const runsBefore = await prisma.syncRun.count();

      const res = await runIncrementalSync(['Account']);
      expect(res).toEqual({ runId: '', status: 'skipped_locked', objects: {} });
      expect(await prisma.syncRun.count()).toBe(runsBefore); // no SyncRun row
      expect(await prisma.account.count()).toBe(0); // nothing synced

      const released = await locker.$queryRaw<
        { unlocked: boolean }[]
      >`SELECT pg_advisory_unlock(hashtext('streamlink_sync')) AS unlocked`;
      expect(released[0].unlocked).toBe(true);
    } finally {
      await locker.$disconnect();
    }

    // Lock released → a subsequent run proceeds normally (proves runSync's own
    // acquire/release cycle also left the lock free).
    const after = await runIncrementalSync(['Account']);
    expect(after.status).toBe('success');
    expect(await prisma.account.count()).toBe(1);
  });
});

describe('jobs/sync handler', () => {
  it('runs a full sync over all nine objects and returns the SyncResult', async () => {
    mockSf.addRecords('Account', [accountRow()]);

    const res = await handler();

    expect(res.status).toBe('success');
    expect(Object.keys(res.objects)).toHaveLength(9);
    expect(res.objects.Account).toEqual({ upserted: 1 });
    expect(res.objects.Case).toEqual({ upserted: 0 }); // unseeded objects sync cleanly as empty
  });
});
```

- [ ] Run it and confirm it fails for the right reason:

```bash
npx vitest run tests/integration/sync.test.ts
```

Expected FAILURE: the suite errors during collection with `Failed to load url ../../lib/sync/runSync` (module does not exist yet; `../../jobs/sync` is equally missing). 0 tests run. If it instead fails on `lib/db`, `lib/salesforce/mock`, or `lib/sync/mappers`, stop — an earlier task is incomplete.

- [ ] Extend the mock to honor `LIMIT` — TDD on the unit level first. In `tests/unit/sf-mock.test.ts`, add this test inside the `describe('mock Salesforce client', …)` block, immediately after the `'applies a strict SystemModstamp > floor…'` test:

```ts
  it('honors a trailing LIMIT clause, applied after sorting', async () => {
    mockSf.addRecords('Account', [
      { Id: '001000000000003AAA', SystemModstamp: '2026-07-17T00:00:00.000Z' },
      { Id: '001000000000001AAA', SystemModstamp: '2026-07-15T00:00:00.000Z' },
      { Id: '001000000000002AAA', SystemModstamp: '2026-07-16T00:00:00.000Z' },
    ]);

    const rows = await getMockSfClient().queryAll(
      'SELECT Id FROM Account ORDER BY SystemModstamp ASC LIMIT 2',
    );
    expect(rows.map((r) => r.Id)).toEqual(['001000000000001AAA', '001000000000002AAA']);
  });
```

- [ ] Run `npx vitest run tests/unit/sf-mock.test.ts` — expected FAILURE: the new test fails with an array of 3 ids where 2 were expected (the mock currently ignores `LIMIT`). The 4 existing tests still pass.

- [ ] Implement `LIMIT` support in `lib/salesforce/mock.ts`. Two edits.

Edit 1 — in the module doc comment, replace:

```ts
 *   - an optional strict floor "SystemModstamp > <ISO datetime>" is extracted
 *     by regex.
```

with:

```ts
 *   - an optional strict floor "SystemModstamp > <ISO datetime>" is extracted
 *     by regex, and
 *   - an optional trailing "LIMIT <n>" is applied after sorting (used by the
 *     sync worker's maxRows / the backfill batches).
```

Edit 2 — replace:

```ts
function modstampMs(record: SfRecord): number {
  return new Date(String(record.SystemModstamp)).getTime();
}
```

with:

```ts
function limitClause(soqlText: string): number | null {
  const m = /\bLIMIT\s+(\d+)\s*$/i.exec(soqlText);
  return m ? Number(m[1]) : null;
}

function modstampMs(record: SfRecord): number {
  return new Date(String(record.SystemModstamp)).getTime();
}
```

Edit 3 — inside `queryAll`, replace:

```ts
      const filtered = floor ? rows.filter((r) => modstampMs(r) > floor.getTime()) : [...rows];
      return filtered.sort((a, b) => modstampMs(a) - modstampMs(b));
```

with:

```ts
      const filtered = floor ? rows.filter((r) => modstampMs(r) > floor.getTime()) : [...rows];
      const sorted = filtered.sort((a, b) => modstampMs(a) - modstampMs(b));
      const limit = limitClause(soqlText);
      return limit === null ? sorted : sorted.slice(0, limit);
```

- [ ] Run `npx vitest run tests/unit/sf-mock.test.ts` — expected PASS (5 tests).

- [ ] Create `lib/sync/runSync.ts` with exactly this content:

```ts
import type { Prisma } from '@prisma/client';
import { prisma } from '../db';
import { logger } from '../logger';
import { getSfClient, type SfRecord } from '../salesforce/client';
import { soql } from '../salesforce/soql';
import { selectClause, SYNC_OBJECTS, whereClause, type SyncObject } from './fieldAllowlist';
import {
  mapAccount,
  mapCase,
  mapContact,
  mapDeliveryConfirmation,
  mapInvoice,
  mapOrder,
  mapOrderLineItem,
  mapSavedCard,
  mapTransaction,
} from './mappers';

/**
 * Incremental Salesforce → portal-Postgres sync.
 *
 * Concurrency: one sync at a time, enforced with a pg SESSION-scoped advisory
 * lock keyed hashtext('streamlink_sync') — this fixes the known unsafety of
 * the checkout repo's runSync pattern. Advisory locks belong to a connection,
 * and Prisma pools connections, so the job MUST run with connection_limit=1
 * on DATABASE_URL to guarantee acquire and release hit the same session (the
 * Lambda/CLI process is short-lived, so a stray lock would die with the
 * process anyway — connection_limit=1 makes it deterministic).
 *
 * SOQL: the SELECT/FROM/WHERE skeleton is assembled from compile-time
 * constants generated by the field allowlist (no user input); the only
 * dynamic values — the high-water-mark Date and the optional maxRows — are
 * interpolated through the soql tagged template per Global Constraints.
 *
 * High-water marks: per-object in sync_state, advanced to the max
 * SystemModstamp seen, only when rows arrived and only after every chunk
 * committed. The floor is strict (>), so re-running after a partial failure
 * re-reads some rows — upserts make that harmless. Rows sharing the exact max
 * modstamp across a maxRows batch boundary would be skipped; SF modstamps are
 * millisecond-precision, so distinct-timestamp data (the normal case) is safe.
 */

export interface SyncResult {
  runId: string;
  status: 'success' | 'failed' | 'skipped_locked';
  objects: Record<string, { upserted: number; error?: string }>;
}

/** Rows per DB transaction — keeps transactions small and retries cheap. */
const CHUNK_SIZE = 200;

/** Upsert payload produced by each object's mapper (drives MAPPERS/DELEGATES). */
interface PayloadByObject {
  Account: Prisma.AccountUncheckedCreateInput;
  Contact: Prisma.ContactUncheckedCreateInput;
  Customer_Orders__c: Prisma.OrderUncheckedCreateInput;
  Order_Line_Item__c: Prisma.OrderLineItemUncheckedCreateInput;
  Case: Prisma.CaseRecordUncheckedCreateInput;
  Transaction__c: Prisma.TransactionUncheckedCreateInput;
  Netsuite_Transactions__c: Prisma.InvoiceUncheckedCreateInput;
  Vendor_Delivery_Notification__c: Prisma.DeliveryConfirmationUncheckedCreateInput;
  Credit_Card__c: Prisma.SavedCardUncheckedCreateInput;
}

const MAPPERS: { [K in SyncObject]: (r: SfRecord) => PayloadByObject[K] } = {
  Account: mapAccount,
  Contact: mapContact,
  Customer_Orders__c: mapOrder,
  Order_Line_Item__c: mapOrderLineItem,
  Case: mapCase,
  Transaction__c: mapTransaction,
  Netsuite_Transactions__c: mapInvoice,
  Vendor_Delivery_Notification__c: mapDeliveryConfirmation,
  Credit_Card__c: mapSavedCard,
};

/**
 * Structural slice of a Prisma model delegate — just the upsert shape the
 * sync loop needs. Every concrete delegate (prisma.account, …) satisfies it:
 * `where { id }` matches the model's unique input and the Unchecked create
 * input doubles as a valid update payload.
 */
interface UpsertDelegate<T extends { id: string }> {
  upsert(args: { where: { id: string }; create: T; update: T }): Prisma.PrismaPromise<unknown>;
}

const DELEGATES: { [K in SyncObject]: UpsertDelegate<PayloadByObject[K]> } = {
  Account: prisma.account,
  Contact: prisma.contact,
  Customer_Orders__c: prisma.order,
  Order_Line_Item__c: prisma.orderLineItem,
  Case: prisma.caseRecord,
  Transaction__c: prisma.transaction,
  Netsuite_Transactions__c: prisma.invoice,
  Vendor_Delivery_Notification__c: prisma.deliveryConfirmation,
  Credit_Card__c: prisma.savedCard,
};

async function tryAcquireLock(): Promise<boolean> {
  const rows = await prisma.$queryRaw<
    { locked: boolean }[]
  >`SELECT pg_try_advisory_lock(hashtext('streamlink_sync')) AS locked`;
  return rows[0]?.locked === true;
}

async function releaseLock(): Promise<void> {
  await prisma.$queryRaw`SELECT pg_advisory_unlock(hashtext('streamlink_sync'))`;
}

/** Map + upsert all rows for one object, in chunks of CHUNK_SIZE per transaction. */
async function upsertRows<K extends SyncObject>(obj: K, rows: SfRecord[]): Promise<void> {
  const map = MAPPERS[obj];
  const delegate = DELEGATES[obj];
  for (let offset = 0; offset < rows.length; offset += CHUNK_SIZE) {
    const chunk = rows.slice(offset, offset + CHUNK_SIZE);
    await prisma.$transaction(
      chunk.map((row) => {
        const payload = map(row);
        return delegate.upsert({ where: { id: payload.id }, create: payload, update: payload });
      }),
    );
  }
}

/** Sync one object; returns rows upserted. Throws on failure (isolated by the caller). */
async function syncObject(obj: SyncObject, maxRows: number | undefined): Promise<number> {
  const client = await getSfClient();
  const state = await prisma.syncState.findUnique({ where: { object: obj } });
  const hwm = state?.highWaterMark ?? new Date(0);

  const staticWhere = whereClause(obj);
  const modstampFilter = soql`SystemModstamp > ${hwm}`;
  const whereSql = staticWhere === '' ? modstampFilter : `${staticWhere} AND ${modstampFilter}`;
  let query = `SELECT ${selectClause(obj)} FROM ${obj} WHERE ${whereSql} ORDER BY SystemModstamp ASC`;
  if (maxRows !== undefined) {
    if (!Number.isInteger(maxRows) || maxRows <= 0) {
      throw new Error(`maxRows must be a positive integer, got ${maxRows}`);
    }
    query += soql` LIMIT ${maxRows}`;
  }

  const rows = await client.queryAll(query);
  if (rows.length === 0) {
    return 0;
  }

  await upsertRows(obj, rows);

  let max = hwm;
  for (const row of rows) {
    const ts = new Date(String(row.SystemModstamp));
    if (!Number.isNaN(ts.getTime()) && ts > max) {
      max = ts;
    }
  }
  await prisma.syncState.upsert({
    where: { object: obj },
    create: { object: obj, highWaterMark: max },
    update: { highWaterMark: max },
  });
  return rows.length;
}

export async function runIncrementalSync(
  objects?: SyncObject[],
  opts?: { maxRows?: number },
): Promise<SyncResult> {
  if (!(await tryAcquireLock())) {
    logger.warn('runIncrementalSync: advisory lock held by another sync — skipping');
    return { runId: '', status: 'skipped_locked', objects: {} };
  }
  try {
    const run = await prisma.syncRun.create({ data: { status: 'running' } });
    const targets: SyncObject[] = objects ?? [...SYNC_OBJECTS];
    const results: SyncResult['objects'] = {};

    for (const obj of targets) {
      try {
        const upserted = await syncObject(obj, opts?.maxRows);
        results[obj] = { upserted };
        logger.info({ obj, upserted }, 'sync object complete');
      } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        results[obj] = { upserted: 0, error: message };
        logger.error({ obj, error: message }, 'sync object failed — continuing with remaining objects');
      }
    }

    const failed = Object.entries(results).filter(([, r]) => r.error !== undefined);
    const status: SyncResult['status'] = failed.length === 0 ? 'success' : 'failed';
    await prisma.syncRun.update({
      where: { id: run.id },
      data: {
        finishedAt: new Date(),
        status,
        // JSON-safe by construction (numbers + optional strings); Prisma's
        // InputJsonValue union does not structurally admit it without help.
        objectsSummary: results as unknown as Prisma.InputJsonValue,
        error:
          failed.length === 0
            ? null
            : failed.map(([obj, r]) => `${obj}: ${r.error ?? 'unknown'}`).join('; '),
      },
    });
    if (status === 'failed') {
      logger.error({ runId: run.id, objects: results }, 'sync failed');
    }
    return { runId: run.id, status, objects: results };
  } finally {
    await releaseLock();
  }
}

/** Timestamp of the most recent fully-successful sync — drives the staleness banner. */
export async function getLastSuccessfulSyncAt(): Promise<Date | null> {
  const run = await prisma.syncRun.findFirst({
    where: { status: 'success' },
    orderBy: { finishedAt: 'desc' },
  });
  return run?.finishedAt ?? null;
}
```

- [ ] Create `jobs/sync.ts` with exactly this content:

```ts
import { pathToFileURL } from 'node:url';
import { logger } from '../lib/logger';
import { runIncrementalSync, type SyncResult } from '../lib/sync/runSync';

/**
 * Sync job entry. Two ways in:
 *  - AWS Lambda: EventBridge Scheduler invokes `handler` every 2 minutes
 *    (bundled by scripts/build-lambdas.mjs).
 *  - CLI (local/dev): `npx tsx jobs/sync.ts`.
 * Both run one incremental sync over all nine objects and log the SyncResult.
 */
export const handler = async (): Promise<SyncResult> => {
  const result = await runIncrementalSync();
  logger.info(
    { runId: result.runId, status: result.status, objects: result.objects },
    'incremental sync finished',
  );
  return result;
};

// CLI entry — true only when this file is the executed script, never on import
// (under vitest/Lambda, argv[1] is the runner, not this file).
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
  handler()
    .then((result) => {
      process.exit(result.status === 'failed' ? 1 : 0);
    })
    .catch((err: unknown) => {
      logger.error({ err }, 'incremental sync crashed');
      process.exit(1);
    });
}
```

- [ ] Run the integration test again:

```bash
npx vitest run tests/integration/sync.test.ts
```

Expected: PASS — 6 tests (5 `runIncrementalSync` + 1 `jobs/sync handler`).

- [ ] CLI smoke run (mock SF, local db — exercises the CLI guard path):

```bash
npx tsx jobs/sync.ts
```

Expected: one pino JSON line `incremental sync finished` with `"status":"success"` and all nine objects reporting `"upserted":0` (the mock store is empty in a fresh process); exit code 0.

- [ ] Typecheck and full unit suite:

```bash
npm run typecheck
npx vitest run tests/unit
```

Expected: zero type errors; all unit tests pass (including the extended `sf-mock` suite).

- [ ] Commit:

```bash
git add lib/sync/runSync.ts jobs/sync.ts lib/salesforce/mock.ts tests/unit/sf-mock.test.ts tests/integration/sync.test.ts
git commit -m "feat: incremental sync engine with advisory lock, per-object high-water marks, and sync job entry"
```

### Task 9: Backfill (batched history seed over the sync engine)

**Files:**
- Create: `lib/sync/backfill.ts`
- Create: `jobs/backfill.ts`
- Test: `tests/unit/backfill-args.test.ts`
- Test: `tests/integration/backfill.test.ts`

**Interfaces:**

Consumes:
- `lib/sync/runSync.ts`: `runIncrementalSync(objects?, opts?: { maxRows?: number })`, `SyncResult` (Task 8).
- `lib/sync/fieldAllowlist.ts`: `SYNC_OBJECTS`, `type SyncObject` (Task 4).
- `lib/logger.ts`: `logger` (Task 3).
- Tests: `mockSf` (Task 5), `prisma` (Task 3), local Postgres (integration only — the unit test needs no database).

Produces:

```ts
// lib/sync/backfill.ts
export async function runBackfill(objects?: SyncObject[], batchSize = 5000): Promise<void>

// jobs/backfill.ts  (Lambda: deployed as index.handler on streamlink-backfill — Task 18's
// runbook invokes it once per object with payload {"object":"<SyncObject>"};
// CLI: npx tsx jobs/backfill.ts [--objects=a,b] [--batch=N])
export async function handler(event?: { object?: SyncObject; batchSize?: number }): Promise<void>
export interface BackfillCliArgs { objects?: SyncObject[]; batchSize?: number }
export function parseBackfillArgs(argv: string[]): BackfillCliArgs
```

**Notes:**
- Backfill is just the incremental engine driven in `maxRows`-sized passes: each pass advances the object's `sync_state` high-water mark, which is exactly what makes the backfill **resumable** — rerunning after an interruption continues from the last completed pass.
- `skipped_locked` **throws** (per the contract: backfill must not silently spin against the 2-minute scheduled sync holding the lock). A per-object `error` in the result **also throws** — a backfill that "completes" past a failing object would silently seed a partial mirror; loudly failing is the only safe behavior. Neither case can loop forever.
- Termination proof: every pass either upserts `count < batchSize` rows (loop exits), or `count === batchSize` (high-water mark strictly advanced past `batchSize` more rows — finite data means finitely many such passes), or throws.
- The Lambda `handler` receives its event as raw JSON, so `event.object` — though typed `SyncObject` — must be validated at runtime against `SYNC_OBJECTS`; an unknown object throws `unknown_object: <value>` so the Task 18 runbook invocation fails loudly instead of silently backfilling all nine objects.

**Steps:**

- [ ] Write the failing unit test for CLI arg parsing. Create `tests/unit/backfill-args.test.ts` with exactly this content:

```ts
import { describe, expect, it } from 'vitest';

// jobs/backfill.ts transitively imports lib/db (PrismaClient is instantiated
// but never queried — no database needed). Belt-and-braces default so the
// import never depends on a local .env:
process.env.DATABASE_URL ??= 'postgresql://streamlink:streamlink@localhost:5433/streamlink';

const { parseBackfillArgs } = await import('../../jobs/backfill');

describe('parseBackfillArgs', () => {
  it('returns undefined objects and batchSize for empty argv', () => {
    expect(parseBackfillArgs([])).toEqual({ objects: undefined, batchSize: undefined });
  });

  it('parses --objects into validated SyncObject names', () => {
    expect(parseBackfillArgs(['--objects=Account,Contact'])).toEqual({
      objects: ['Account', 'Contact'],
      batchSize: undefined,
    });
  });

  it('parses --batch as a positive integer', () => {
    expect(parseBackfillArgs(['--batch=250'])).toEqual({ objects: undefined, batchSize: 250 });
  });

  it('parses both flags together in any order', () => {
    expect(parseBackfillArgs(['--batch=10', '--objects=Customer_Orders__c'])).toEqual({
      objects: ['Customer_Orders__c'],
      batchSize: 10,
    });
  });

  it('rejects unknown sync objects', () => {
    expect(() => parseBackfillArgs(['--objects=Account,Bogus__c'])).toThrow(/unknown sync object/i);
  });

  it('rejects non-positive or non-integer batch sizes', () => {
    expect(() => parseBackfillArgs(['--batch=0'])).toThrow(/positive integer/);
    expect(() => parseBackfillArgs(['--batch=2.5'])).toThrow(/positive integer/);
    expect(() => parseBackfillArgs(['--batch=abc'])).toThrow(/positive integer/);
  });

  it('rejects unrecognised arguments', () => {
    expect(() => parseBackfillArgs(['--nope'])).toThrow(/unknown argument/);
  });
});
```

- [ ] Run `npx vitest run tests/unit/backfill-args.test.ts` — expected FAILURE: `Failed to load url ../../jobs/backfill` (module does not exist yet). 0 tests run.

- [ ] Write the failing integration test. Create `tests/integration/backfill.test.ts` with exactly this content:

```ts
// Integration test: requires the Task 2 docker Postgres (npm run db:up) with
// migrations applied. SF_MODE=mock — no network.
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import type { SfRecord } from '../../lib/salesforce/client';
import type { SyncObject } from '../../lib/sync/fieldAllowlist';

// Same env discipline as tests/integration/sync.test.ts: pin the pool to one
// connection BEFORE lib/db loads so session-scoped advisory locks behave.
process.env.SF_MODE = 'mock';
process.env.DATABASE_URL ??= 'postgresql://streamlink:streamlink@localhost:5433/streamlink';
if (!process.env.DATABASE_URL.includes('connection_limit=')) {
  process.env.DATABASE_URL += process.env.DATABASE_URL.includes('?')
    ? '&connection_limit=1'
    : '?connection_limit=1';
}

const { prisma } = await import('../../lib/db');
const { mockSf } = await import('../../lib/salesforce/mock');
const { runBackfill } = await import('../../lib/sync/backfill');
const { handler } = await import('../../jobs/backfill');

// Account rows only: Account has no FK dependencies, so the batch mechanics
// are tested without cross-object ordering noise. Distinct modstamps.
function backfillAccountRow(n: number): SfRecord {
  const day = String(n).padStart(2, '0'); // n in 1..12 → 2026-06-01 .. 2026-06-12
  return {
    attributes: { type: 'Account', url: '/services/data/v59.0/sobjects/Account/x' },
    Id: `001BF00000000${day}AAA`, // 18 chars
    Name: `Backfill Account ${n}`,
    RecordType: { attributes: { type: 'RecordType', url: '/x' }, DeveloperName: 'Business_Account' },
    IsDeleted: false,
    SystemModstamp: `2026-06-${day}T00:00:00.000Z`,
  };
}

async function resetSyncTables(): Promise<void> {
  await prisma.$executeRaw`TRUNCATE TABLE order_line_items, orders, contacts, accounts, cases, transactions, invoices, delivery_confirmations, saved_cards, sync_state, sync_runs CASCADE`;
}

beforeEach(async () => {
  mockSf.reset();
  await resetSyncTables();
});

afterAll(async () => {
  await prisma.$disconnect();
});

describe('runBackfill', () => {
  it('drains 12 rows in exactly 3 passes of batchSize 5 and lands the hwm on the max modstamp', async () => {
    mockSf.addRecords(
      'Account',
      Array.from({ length: 12 }, (_, i) => backfillAccountRow(i + 1)),
    );

    await runBackfill(['Account'], 5);

    // Pass sizes 5, 5, 2 — one SyncRun per pass, all successful.
    expect(await prisma.syncRun.count()).toBe(3);
    expect(await prisma.syncRun.count({ where: { status: 'success' } })).toBe(3);
    expect(await prisma.account.count()).toBe(12);

    const state = await prisma.syncState.findUniqueOrThrow({ where: { object: 'Account' } });
    expect(state.highWaterMark.toISOString()).toBe('2026-06-12T00:00:00.000Z');
  });

  it('terminates with one trailing empty pass when the row count is an exact multiple of batchSize', async () => {
    mockSf.addRecords(
      'Account',
      Array.from({ length: 10 }, (_, i) => backfillAccountRow(i + 1)),
    );

    await runBackfill(['Account'], 5);

    // Passes: 5, 5, 0 — the empty third pass proves the loop cannot spin.
    expect(await prisma.syncRun.count()).toBe(3);
    expect(await prisma.account.count()).toBe(10);
  });

  it('throws instead of spinning when the sync lock is held elsewhere', async () => {
    const url = process.env.DATABASE_URL;
    if (url === undefined) throw new Error('DATABASE_URL not set');
    const locker = new PrismaClient({ datasourceUrl: url });
    try {
      const acquired = await locker.$queryRaw<
        { locked: boolean }[]
      >`SELECT pg_try_advisory_lock(hashtext('streamlink_sync')) AS locked`;
      expect(acquired[0].locked).toBe(true);

      await expect(runBackfill(['Account'], 5)).rejects.toThrow(/lock is held/);
      expect(await prisma.syncRun.count()).toBe(0); // skipped_locked writes no SyncRun
    } finally {
      await locker.$queryRaw`SELECT pg_advisory_unlock(hashtext('streamlink_sync'))`;
      await locker.$disconnect();
    }
  });
});

describe('jobs/backfill handler (Lambda entry — Task 18 invokes with {"object":"<SyncObject>"})', () => {
  it('backfills only the object named in the event payload', async () => {
    mockSf.addRecords('Account', [backfillAccountRow(1)]);
    mockSf.addRecords('Contact', [
      {
        attributes: { type: 'Contact', url: '/services/data/v59.0/sobjects/Contact/x' },
        Id: '003BF0000000001AAA',
        AccountId: '001BF0000000001AAA',
        Email: 'jane.foreman@example.com',
        FirstName: 'Jane',
        LastName: 'Foreman',
        Phone: null,
        IsPersonAccount: false,
        IsDeleted: false,
        SystemModstamp: '2026-06-01T00:00:00.000Z',
      },
    ]);

    await handler({ object: 'Account' });

    expect(await prisma.account.count()).toBe(1);
    expect(await prisma.contact.count()).toBe(0); // untouched — only Account ran
    expect(await prisma.syncState.findUnique({ where: { object: 'Contact' } })).toBeNull();
  });

  it('rejects an unknown object in the event payload without touching the database', async () => {
    // The Lambda payload is raw JSON — the SyncObject type is not enforced at
    // runtime, hence the cast to exercise the handler's own validation.
    await expect(handler({ object: 'Bogus' as SyncObject })).rejects.toThrow(/unknown_object: Bogus/);
    expect(await prisma.syncRun.count()).toBe(0);
  });
});
```

- [ ] Run `npx vitest run tests/integration/backfill.test.ts` — expected FAILURE: `Failed to load url ../../lib/sync/backfill` (module does not exist yet; `../../jobs/backfill` is equally missing). 0 tests run.

- [ ] Create `lib/sync/backfill.ts` with exactly this content:

```ts
import { logger } from '../logger';
import { SYNC_OBJECTS, type SyncObject } from './fieldAllowlist';
import { runIncrementalSync } from './runSync';

/**
 * One-time history seed (~250k orders, ~1M OLIs): drains each object through
 * runIncrementalSync in maxRows-sized passes. Each completed pass advances the
 * object's sync_state high-water mark, so the backfill is RESUMABLE — rerun
 * after any interruption and it continues where the last pass left off.
 *
 * Failure policy: loud, never spinning.
 *  - skipped_locked → throw. Retrying immediately would spin against the
 *    scheduled 2-minute sync holding the lock (contract: backfill must not
 *    silently spin). Stop the scheduler (or wait) and rerun.
 *  - a per-object error → throw. An errored pass reports upserted: 0, so
 *    continuing would end the loop and "complete" a silently partial mirror.
 */
export async function runBackfill(objects?: SyncObject[], batchSize = 5000): Promise<void> {
  if (!Number.isInteger(batchSize) || batchSize <= 0) {
    throw new Error(`runBackfill: batchSize must be a positive integer, got ${batchSize}`);
  }
  const targets: SyncObject[] = objects ?? [...SYNC_OBJECTS];
  for (const obj of targets) {
    let pass = 0;
    let total = 0;
    for (;;) {
      const res = await runIncrementalSync([obj], { maxRows: batchSize });
      if (res.status === 'skipped_locked') {
        throw new Error(
          `runBackfill: sync lock is held by another process (object ${obj}) — aborting instead of spinning`,
        );
      }
      const objectResult = res.objects[obj];
      if (objectResult?.error !== undefined) {
        throw new Error(`runBackfill: ${obj} failed on pass ${pass + 1}: ${objectResult.error}`);
      }
      const count = objectResult?.upserted ?? 0;
      pass += 1;
      total += count;
      logger.info({ obj, pass, count, total }, 'backfill pass complete');
      if (count < batchSize) {
        break;
      }
    }
    logger.info({ obj, passes: pass, total }, 'backfill object complete');
  }
}
```

- [ ] Create `jobs/backfill.ts` with exactly this content:

```ts
import { pathToFileURL } from 'node:url';
import { logger } from '../lib/logger';
import { runBackfill } from '../lib/sync/backfill';
import { SYNC_OBJECTS, type SyncObject } from '../lib/sync/fieldAllowlist';

/**
 * One-time backfill entry. Two ways in:
 *  - AWS Lambda: deployed as `streamlink-backfill` with `--handler
 *    index.handler` (bundled by scripts/build-lambdas.mjs). The Task 18
 *    runbook invokes it once per object with payload {"object":"<SyncObject>"};
 *    an empty payload backfills all nine objects in SYNC_OBJECTS order.
 *  - CLI (local/dev): npx tsx jobs/backfill.ts [--objects=Account,Contact] [--batch=5000]
 * Resumable — see lib/sync/backfill.ts. Run against prod SF with SF_MODE=real
 * and the sync scheduler paused (backfill aborts if the sync lock is held).
 */

export interface BackfillCliArgs {
  objects?: SyncObject[];
  batchSize?: number;
}

function isSyncObject(value: string): value is SyncObject {
  return (SYNC_OBJECTS as readonly string[]).includes(value);
}

/**
 * Lambda entry. The event arrives as raw JSON, so event.object — though typed
 * SyncObject — is validated at runtime: an unknown object throws instead of
 * silently backfilling everything.
 */
export async function handler(event?: { object?: SyncObject; batchSize?: number }): Promise<void> {
  if (event?.object !== undefined && !isSyncObject(event.object)) {
    throw new Error(`unknown_object: ${String(event.object)}`);
  }
  await runBackfill(event?.object ? [event.object] : undefined, event?.batchSize ?? 5000);
}

export function parseBackfillArgs(argv: string[]): BackfillCliArgs {
  let objects: SyncObject[] | undefined;
  let batchSize: number | undefined;
  for (const arg of argv) {
    if (arg.startsWith('--objects=')) {
      const names = arg
        .slice('--objects='.length)
        .split(',')
        .map((name) => name.trim())
        .filter((name) => name !== '');
      const invalid = names.filter((name) => !isSyncObject(name));
      if (invalid.length > 0) {
        throw new Error(
          `unknown sync object(s): ${invalid.join(', ')} (valid: ${SYNC_OBJECTS.join(', ')})`,
        );
      }
      objects = names.filter(isSyncObject);
    } else if (arg.startsWith('--batch=')) {
      const raw = arg.slice('--batch='.length);
      const parsed = Number(raw);
      if (!Number.isInteger(parsed) || parsed <= 0) {
        throw new Error(`--batch must be a positive integer, got: ${raw}`);
      }
      batchSize = parsed;
    } else {
      throw new Error(`unknown argument: ${arg} (usage: backfill [--objects=a,b] [--batch=N])`);
    }
  }
  return { objects, batchSize };
}

// CLI entry — true only when this file is the executed script, never on import
// (under vitest/Lambda, argv[1] is the runner, not this file).
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
  let args: BackfillCliArgs;
  try {
    args = parseBackfillArgs(process.argv.slice(2));
  } catch (err) {
    logger.error(
      { error: err instanceof Error ? err.message : String(err) },
      'invalid backfill arguments',
    );
    process.exit(1);
  }
  runBackfill(args.objects, args.batchSize ?? 5000)
    .then(() => {
      logger.info('backfill complete');
      process.exit(0);
    })
    .catch((err: unknown) => {
      logger.error({ err }, 'backfill failed');
      process.exit(1);
    });
}
```

- [ ] Run both new suites:

```bash
npx vitest run tests/unit/backfill-args.test.ts
npx vitest run tests/integration/backfill.test.ts
```

Expected: PASS — 7 unit tests, 5 integration tests (3 `runBackfill` + 2 `jobs/backfill handler`).

- [ ] CLI smoke run (proves flag parsing + the guard end-to-end against the mock):

```bash
npx tsx jobs/backfill.ts --objects=Account --batch=100
```

Expected: pino lines `backfill pass complete` (`count: 0` in a fresh process — mock store is empty), `backfill object complete`, `backfill complete`; exit code 0. Then confirm a bad flag fails: `npx tsx jobs/backfill.ts --objects=Nope` → `invalid backfill arguments` line, exit code 1.

- [ ] Typecheck and the full test suite. The two sync integration suites truncate shared tables and count global `sync_runs` rows, so integration files must not run in parallel workers:

```bash
npm run typecheck
npx vitest run tests/unit
npx vitest run tests/integration --no-file-parallelism
```

Expected: zero type errors; all unit and integration tests pass.

- [ ] Commit:

```bash
git add lib/sync/backfill.ts jobs/backfill.ts tests/unit/backfill-args.test.ts tests/integration/backfill.test.ts
git commit -m "feat: resumable batched backfill over the incremental sync engine with CLI entry"
```
### Task 10: Magic-Link Auth Core + Email

**Files:**
- Create: `lib/auth/email.ts`, `lib/auth/magicLink.ts`
- Test: `tests/unit/email.test.ts`, `tests/integration/auth.test.ts`

**Interfaces:**
- Consumes:
  - `export const prisma: PrismaClient` from `lib/db.ts` (Task 2/3 scaffolding) — Prisma models `Account`, `Contact`, `MagicLink`, `PortalUser`, `Session` from the authoritative schema (Task 2).
  - `export const logger: pino.Logger` from `lib/logger.ts`.
  - Local Postgres from Task 2's docker-compose (port 5433, database `streamlink`) — required for `tests/integration/auth.test.ts` only. `tests/unit/email.test.ts` must run with no database.
  - Non-secret config from `process.env`: `EMAIL_MODE` (`log` default | `ses`), `SES_FROM_ADDRESS`, `AWS_REGION`, `APP_BASE_URL`. No `getSecret()` call is needed in this task: the SES client deliberately uses the AWS SDK default credential provider chain (IAM role at runtime), so no credential is ever read in code.
- Produces (Task 11 and later route/e2e tasks rely on these exact exports):
  - `lib/auth/email.ts`: `export async function sendMagicLinkEmail(to: string, link: string): Promise<void>`, `export function _setSesClientForTests(client: MagicLinkSesClient | null): void`, `export interface MagicLinkSesClient { send(command: SendEmailCommand): Promise<unknown> }`
  - `lib/auth/magicLink.ts`: `export async function requestMagicLink(email: string, ip: string): Promise<void>`, `export async function verifyMagicLink(rawToken: string): Promise<{ sessionToken: string; accountIds: string[] } | null>`, and helper `export function hashToken(value: string): string` (sha256 hex — Task 11's `session.ts` imports this to hash session cookies).

**Prerequisite check:** the Task 2 database must be up and migrated before the integration-test steps: `docker compose up -d` then (if not already applied) `npx prisma migrate deploy`.

- [ ] **Step 1 — failing unit test for the email sender.** Create `tests/unit/email.test.ts` with exactly:

```ts
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SendEmailCommand } from '@aws-sdk/client-ses'
import { logger } from '../../lib/logger'
import { _setSesClientForTests, sendMagicLinkEmail } from '../../lib/auth/email'

const LINK = 'http://localhost:3000/auth/verify?token=abc123def456'

describe('sendMagicLinkEmail', () => {
  afterEach(() => {
    vi.unstubAllEnvs()
    vi.restoreAllMocks()
    _setSesClientForTests(null)
  })

  it('logs the link in log mode', async () => {
    vi.stubEnv('EMAIL_MODE', 'log')
    const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => undefined)

    await sendMagicLinkEmail('user@example.com', LINK)

    expect(infoSpy).toHaveBeenCalledWith({ to: 'user@example.com', link: LINK }, 'magic link')
  })

  it('defaults to log mode when EMAIL_MODE is unset', async () => {
    vi.stubEnv('EMAIL_MODE', '')
    const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => undefined)
    const send = vi.fn()
    _setSesClientForTests({ send })

    await sendMagicLinkEmail('user@example.com', LINK)

    expect(infoSpy).toHaveBeenCalledWith({ to: 'user@example.com', link: LINK }, 'magic link')
    expect(send).not.toHaveBeenCalled()
  })

  it('sends via SES in ses mode with the from address and both bodies', async () => {
    vi.stubEnv('EMAIL_MODE', 'ses')
    vi.stubEnv('SES_FROM_ADDRESS', 'portal@ldrsiteservices.com')
    const send = vi.fn().mockResolvedValue({})
    _setSesClientForTests({ send })

    await sendMagicLinkEmail('user@example.com', LINK)

    expect(send).toHaveBeenCalledTimes(1)
    const command = send.mock.calls[0]?.[0] as SendEmailCommand
    expect(command.input.Source).toBe('portal@ldrsiteservices.com')
    expect(command.input.Destination?.ToAddresses).toEqual(['user@example.com'])
    expect(command.input.Message?.Subject?.Data).toContain('Streamlink')
    expect(command.input.Message?.Body?.Html?.Data).toContain(LINK)
    expect(command.input.Message?.Body?.Text?.Data).toContain(LINK)
  })

  it('throws in ses mode when SES_FROM_ADDRESS is missing', async () => {
    vi.stubEnv('EMAIL_MODE', 'ses')
    vi.stubEnv('SES_FROM_ADDRESS', '')
    _setSesClientForTests({ send: vi.fn() })

    await expect(sendMagicLinkEmail('user@example.com', LINK)).rejects.toThrow('SES_FROM_ADDRESS')
  })
})
```

- [ ] **Step 2 — run it, confirm the failure.** Run `npm test -- tests/unit/email.test.ts`. Expected failure: module-resolution error — `Failed to resolve import "../../lib/auth/email"` (the file does not exist yet).

- [ ] **Step 3 — implement the email sender.** Create `lib/auth/email.ts` with exactly:

```ts
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses'
import { logger } from '../logger'

/**
 * Minimal structural interface so tests can inject a fake client.
 * A real SESClient satisfies it.
 */
export interface MagicLinkSesClient {
  send(command: SendEmailCommand): Promise<unknown>
}

let sesClient: MagicLinkSesClient | null = null

export function _setSesClientForTests(client: MagicLinkSesClient | null): void {
  sesClient = client
}

function getSesClient(): MagicLinkSesClient {
  if (sesClient === null) {
    // Credentials come from the AWS SDK default provider chain (IAM role at
    // runtime) — nothing secret is read in code, so getSecret() is not needed.
    sesClient = new SESClient({ region: process.env.AWS_REGION ?? 'us-east-1' })
  }
  return sesClient
}

function htmlBody(link: string): string {
  return [
    '<div style="font-family: Arial, Helvetica, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">',
    '<h1 style="font-size: 20px; color: #1a1a2e; margin: 0 0 16px;">Streamlink by LDR Site Services</h1>',
    '<p style="font-size: 15px; color: #333333; margin: 0 0 8px;">Click the button below to sign in. This link expires in 15 minutes and can be used once.</p>',
    `<p style="margin: 24px 0;"><a href="${link}" style="background: #1a1a2e; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; display: inline-block;">Sign in to Streamlink</a></p>`,
    `<p style="font-size: 13px; color: #666666;">Or paste this link into your browser:<br>${link}</p>`,
    '<p style="font-size: 13px; color: #666666;">If you did not request this, you can safely ignore this email.</p>',
    '</div>',
  ].join('\n')
}

function textBody(link: string): string {
  return [
    'Sign in to Streamlink by LDR Site Services',
    '',
    'Use this link to sign in (expires in 15 minutes, single use):',
    link,
    '',
    'If you did not request this, you can safely ignore this email.',
  ].join('\n')
}

export async function sendMagicLinkEmail(to: string, link: string): Promise<void> {
  const mode = process.env.EMAIL_MODE
  if (mode !== 'ses') {
    // EMAIL_MODE=log is the default for dev/test.
    logger.info({ to, link }, 'magic link')
    return
  }

  const from = process.env.SES_FROM_ADDRESS
  if (!from) {
    throw new Error('SES_FROM_ADDRESS must be set when EMAIL_MODE=ses')
  }

  await getSesClient().send(
    new SendEmailCommand({
      Source: from,
      Destination: { ToAddresses: [to] },
      Message: {
        Subject: { Data: 'Your Streamlink sign-in link', Charset: 'UTF-8' },
        Body: {
          Html: { Data: htmlBody(link), Charset: 'UTF-8' },
          Text: { Data: textBody(link), Charset: 'UTF-8' },
        },
      },
    }),
  )
}
```

- [ ] **Step 4 — unit tests pass.** Run `npm test -- tests/unit/email.test.ts`. Expected: 4 tests PASS.

- [ ] **Step 5 — failing integration test for the magic-link core.** Create `tests/integration/auth.test.ts` with exactly:

```ts
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { prisma } from '../../lib/db'
import { sendMagicLinkEmail } from '../../lib/auth/email'
import { hashToken, requestMagicLink, verifyMagicLink } from '../../lib/auth/magicLink'

vi.mock('../../lib/auth/email', () => ({
  sendMagicLinkEmail: vi.fn().mockResolvedValue(undefined),
}))

const sendEmailMock = vi.mocked(sendMagicLinkEmail)

// BASE is anchored to the REAL clock at load time. Only `Date` is faked (see
// beforeEach), so Postgres-generated created_at defaults (DEFAULT now()) stay
// within a second or two of BASE — inside every rate-limit window we test.
const BASE = new Date()
const MIN = 60_000
const DAY = 24 * 60 * MIN

const ACCOUNT_A = '001000000000001AAA'
const ACCOUNT_B = '001000000000002AAA'
const CONTACT_1 = '003000000000001AAA'
const CONTACT_2 = '003000000000002AAA'

async function resetDb(): Promise<void> {
  await prisma.$transaction([
    prisma.auditLog.deleteMany(),
    prisma.session.deleteMany(),
    prisma.serviceRequest.deleteMany(),
    prisma.magicLink.deleteMany(),
    prisma.portalUser.deleteMany(),
    prisma.orderLineItem.deleteMany(),
    prisma.order.deleteMany(),
    prisma.contact.deleteMany(),
    prisma.account.deleteMany(),
  ])
}

async function seedContact(email: string, accountId: string, contactId: string): Promise<void> {
  await prisma.account.upsert({
    where: { id: accountId },
    create: { id: accountId, name: `Account ${accountId}`, recordType: 'Business_Account', sfModstamp: BASE },
    update: {},
  })
  await prisma.contact.create({ data: { id: contactId, accountId, email, sfModstamp: BASE } })
}

async function requestAndExtractToken(email: string, ip: string): Promise<string> {
  await requestMagicLink(email, ip)
  expect(sendEmailMock).toHaveBeenCalledTimes(1)
  const link = sendEmailMock.mock.calls[0]?.[1]
  if (!link) throw new Error('no magic link was sent')
  const token = new URL(link).searchParams.get('token')
  if (!token) throw new Error('magic link has no token param')
  return token
}

describe('magic-link auth core', () => {
  beforeEach(async () => {
    vi.useFakeTimers({ toFake: ['Date'] })
    vi.setSystemTime(BASE)
    vi.stubEnv('APP_BASE_URL', 'http://localhost:3000')
    sendEmailMock.mockClear()
    await resetDb()
  })

  afterEach(() => {
    vi.unstubAllEnvs()
    vi.useRealTimers()
  })

  afterAll(async () => {
    await prisma.$disconnect()
  })

  it('happy path: request normalizes email, stores a hashed token, verify creates user + session', async () => {
    await seedContact('customer@example.com', ACCOUNT_A, CONTACT_1)

    const token = await requestAndExtractToken('  Customer@Example.COM ', '10.0.0.1')

    const [to, link] = sendEmailMock.mock.calls[0] ?? []
    expect(to).toBe('customer@example.com')
    expect(link).toBe(`http://localhost:3000/auth/verify?token=${token}`)

    const stored = await prisma.magicLink.findUniqueOrThrow({ where: { tokenHash: hashToken(token) } })
    expect(stored.email).toBe('customer@example.com')
    expect(stored.requestIp).toBe('10.0.0.1')
    expect(stored.expiresAt.getTime()).toBe(BASE.getTime() + 15 * MIN)

    const result = await verifyMagicLink(token)
    if (!result) throw new Error('expected a session')
    expect(result.accountIds).toEqual([ACCOUNT_A])
    expect(result.sessionToken).toMatch(/^[0-9a-f]{64}$/)

    const session = await prisma.session.findUniqueOrThrow({
      where: { tokenHash: hashToken(result.sessionToken) },
    })
    expect(session.accountId).toBe(ACCOUNT_A)
    expect(session.expiresAt.getTime()).toBe(BASE.getTime() + 30 * DAY)

    const user = await prisma.portalUser.findUniqueOrThrow({ where: { email: 'customer@example.com' } })
    expect(session.userId).toBe(user.id)
    expect(user.lastLoginAt?.getTime()).toBe(BASE.getTime())

    const used = await prisma.magicLink.findUniqueOrThrow({ where: { tokenHash: hashToken(token) } })
    expect(used.usedAt?.getTime()).toBe(BASE.getTime())

    const audit = await prisma.auditLog.findFirst({ where: { action: 'login' } })
    expect(audit).not.toBeNull()
    expect(audit?.userId).toBe(user.id)
    expect(audit?.accountId).toBe(ACCOUNT_A)
    expect(audit?.detail).toEqual({ via: 'magic_link' })
  })

  it('rejects an expired link', async () => {
    await seedContact('customer@example.com', ACCOUNT_A, CONTACT_1)
    const token = await requestAndExtractToken('customer@example.com', '10.0.0.1')

    vi.setSystemTime(new Date(BASE.getTime() + 16 * MIN))

    expect(await verifyMagicLink(token)).toBeNull()
  })

  it('is single-use: the second verify returns null', async () => {
    await seedContact('customer@example.com', ACCOUNT_A, CONTACT_1)
    const token = await requestAndExtractToken('customer@example.com', '10.0.0.1')

    expect(await verifyMagicLink(token)).not.toBeNull()
    expect(await verifyMagicLink(token)).toBeNull()
  })

  it('silently drops the 4th request for one email within 15 minutes', async () => {
    await seedContact('customer@example.com', ACCOUNT_A, CONTACT_1)
    await prisma.magicLink.createMany({
      data: [1, 2, 3].map((n) => ({
        email: 'customer@example.com',
        tokenHash: hashToken(`prior-email-${n}`),
        createdAt: new Date(BASE.getTime() - 5 * MIN),
        expiresAt: new Date(BASE.getTime() + 10 * MIN),
        requestIp: '10.0.0.1',
      })),
    })

    await requestMagicLink('customer@example.com', '10.0.0.1')

    expect(sendEmailMock).not.toHaveBeenCalled()
    expect(await prisma.magicLink.count({ where: { email: 'customer@example.com' } })).toBe(3)
  })

  it('silently drops the 11th request from one IP within 60 minutes', async () => {
    await seedContact('customer@example.com', ACCOUNT_A, CONTACT_1)
    await prisma.magicLink.createMany({
      data: Array.from({ length: 10 }, (_, i) => ({
        email: `other-${i}@example.com`,
        tokenHash: hashToken(`prior-ip-${i}`),
        createdAt: new Date(BASE.getTime() - 30 * MIN),
        expiresAt: new Date(BASE.getTime() - 15 * MIN),
        requestIp: '9.9.9.9',
      })),
    })

    await requestMagicLink('customer@example.com', '9.9.9.9')

    expect(sendEmailMock).not.toHaveBeenCalled()
    expect(await prisma.magicLink.count({ where: { email: 'customer@example.com' } })).toBe(0)
  })

  it('does nothing for unknown or deleted-contact emails', async () => {
    await prisma.account.create({
      data: { id: ACCOUNT_A, name: 'Acme Retail', recordType: 'Business_Account', sfModstamp: BASE },
    })
    await prisma.contact.create({
      data: { id: CONTACT_1, accountId: ACCOUNT_A, email: 'deleted@example.com', isDeleted: true, sfModstamp: BASE },
    })

    await requestMagicLink('stranger@example.com', '10.0.0.1')
    await requestMagicLink('deleted@example.com', '10.0.0.1')

    expect(sendEmailMock).not.toHaveBeenCalled()
    expect(await prisma.magicLink.count()).toBe(0)
  })

  it('leaves session.accountId null when the email maps to two accounts', async () => {
    await seedContact('shared@example.com', ACCOUNT_A, CONTACT_1)
    await seedContact('shared@example.com', ACCOUNT_B, CONTACT_2)

    const token = await requestAndExtractToken('shared@example.com', '10.0.0.1')
    const result = await verifyMagicLink(token)
    if (!result) throw new Error('expected a session')

    expect(new Set(result.accountIds)).toEqual(new Set([ACCOUNT_A, ACCOUNT_B]))

    const session = await prisma.session.findUniqueOrThrow({
      where: { tokenHash: hashToken(result.sessionToken) },
    })
    expect(session.accountId).toBeNull()
  })
})
```

- [ ] **Step 6 — run it, confirm the failure.** With the database up (`docker compose up -d`), run `npm test -- tests/integration/auth.test.ts`. Expected failure: `Failed to resolve import "../../lib/auth/magicLink"`.

- [ ] **Step 7 — implement the magic-link core.** Create `lib/auth/magicLink.ts` with exactly:

```ts
import { createHash, randomBytes } from 'node:crypto'
import { prisma } from '../db'
import { sendMagicLinkEmail } from './email'

const MAGIC_LINK_TTL_MS = 15 * 60 * 1000
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
const EMAIL_RATE_WINDOW_MS = 15 * 60 * 1000
const EMAIL_RATE_MAX = 3
const IP_RATE_WINDOW_MS = 60 * 60 * 1000
const IP_RATE_MAX = 10

/** sha256 hex digest — shared by magic-link and session token storage (Task 11). */
export function hashToken(value: string): string {
  return createHash('sha256').update(value).digest('hex')
}

export async function requestMagicLink(email: string, ip: string): Promise<void> {
  const normalizedEmail = email.trim().toLowerCase()
  const now = new Date()

  // Rate limits FIRST — silent drops, identical to the unknown-email path,
  // so the caller can never distinguish outcomes (anti-enumeration).
  const recentForEmail = await prisma.magicLink.count({
    where: {
      email: normalizedEmail,
      createdAt: { gt: new Date(now.getTime() - EMAIL_RATE_WINDOW_MS) },
    },
  })
  if (recentForEmail >= EMAIL_RATE_MAX) return

  const recentForIp = await prisma.magicLink.count({
    where: {
      requestIp: ip,
      createdAt: { gt: new Date(now.getTime() - IP_RATE_WINDOW_MS) },
    },
  })
  if (recentForIp >= IP_RATE_MAX) return

  // Eligibility: the email must belong to a live synced contact. The accounts
  // table only ever contains eligible record types by sync design, so no
  // extra record-type check is needed here.
  const contact = await prisma.contact.findFirst({
    where: { email: normalizedEmail, isDeleted: false },
  })
  if (!contact) return

  const token = randomBytes(32).toString('hex')
  await prisma.magicLink.create({
    data: {
      email: normalizedEmail,
      tokenHash: hashToken(token),
      expiresAt: new Date(now.getTime() + MAGIC_LINK_TTL_MS),
      requestIp: ip,
    },
  })

  const baseUrl = process.env.APP_BASE_URL ?? 'http://localhost:3000'
  await sendMagicLinkEmail(normalizedEmail, `${baseUrl}/auth/verify?token=${token}`)
}

export async function verifyMagicLink(
  rawToken: string,
): Promise<{ sessionToken: string; accountIds: string[] } | null> {
  const tokenHash = hashToken(rawToken)
  const now = new Date()

  // Atomic single-use claim: only one concurrent verify can flip usedAt.
  const claimed = await prisma.magicLink.updateMany({
    where: { tokenHash, usedAt: null, expiresAt: { gt: now } },
    data: { usedAt: now },
  })
  if (claimed.count === 0) return null

  const link = await prisma.magicLink.findUniqueOrThrow({ where: { tokenHash } })

  const user = await prisma.portalUser.upsert({
    where: { email: link.email },
    create: { email: link.email, lastLoginAt: now },
    update: { lastLoginAt: now },
  })

  const memberships = await prisma.contact.findMany({
    where: { email: link.email, isDeleted: false },
    select: { accountId: true },
    distinct: ['accountId'],
  })
  const accountIds = memberships.map((m) => m.accountId)
  const [firstAccountId] = accountIds
  const soleAccountId =
    accountIds.length === 1 && firstAccountId !== undefined ? firstAccountId : null

  const sessionToken = randomBytes(32).toString('hex')
  await prisma.session.create({
    data: {
      tokenHash: hashToken(sessionToken),
      userId: user.id,
      accountId: soleAccountId,
      expiresAt: new Date(now.getTime() + SESSION_TTL_MS),
    },
  })

  // Spec: audit_log covers logins.
  await prisma.auditLog.create({
    data: { userId: user.id, accountId: soleAccountId, action: 'login', detail: { via: 'magic_link' } },
  })

  return { sessionToken, accountIds }
}
```

- [ ] **Step 8 — integration tests pass.** Run `npm test -- tests/integration/auth.test.ts`. Expected: 7 tests PASS.

- [ ] **Step 9 — full suite green, commit.** Run `npm test` (with the database up). Expected: all suites PASS. Then:

```sh
git add lib/auth/email.ts lib/auth/magicLink.ts tests/unit/email.test.ts tests/integration/auth.test.ts
git commit -m "feat: magic-link request/verify with rate limits and email delivery"
```

### Task 11: Sessions, Middleware, Login UI

**Files:**
- Create: `lib/auth/session.ts`, `middleware.ts`, `app/login/page.tsx`, `app/login/LoginForm.tsx`, `app/login/sent/page.tsx`, `app/api/auth/request-link/route.ts`, `app/auth/verify/route.ts`, `app/auth/logout/route.ts`, `app/select-account/page.tsx`
  (Note: `app/login/LoginForm.tsx` is a colocated client component, not a route — the authoritative File Structure lists routes only. If Task 1 scaffolding already created a stub `middleware.ts`, replace its contents entirely with the code below.)
- Test: `tests/integration/session.test.ts`, `tests/unit/middleware.test.ts`, `tests/unit/authRoutes.test.ts`

**Interfaces:**
- Consumes:
  - From Task 10 (`lib/auth/magicLink.ts`): `requestMagicLink(email: string, ip: string): Promise<void>`, `verifyMagicLink(rawToken: string): Promise<{ sessionToken: string; accountIds: string[] } | null>`, `hashToken(value: string): string`.
  - `prisma: PrismaClient` (`lib/db.ts`), `logger: pino.Logger` (`lib/logger.ts`), Prisma models `Session`, `PortalUser`, `Contact`, `Account`, `AuditLog`.
  - Local Postgres (Task 2 docker-compose) for `tests/integration/session.test.ts` only. The two unit-test files must not need a running database (`tests/unit/authRoutes.test.ts` transitively imports `lib/db.ts`, which instantiates the Prisma singleton but never queries — instantiation does not open a connection).
- Produces (every portal page/domain task and the e2e task rely on these exact exports):
  - `lib/auth/session.ts`: `export interface PortalSession { userId: string; email: string; accountId: string | null; accountName: string | null }`, `export const SESSION_COOKIE = 'sl_session'`, `export async function getSession(): Promise<PortalSession | null>`, `export async function requireSession(): Promise<PortalSession & { accountId: string }>`, `export async function selectAccount(sessionToken: string, accountId: string): Promise<boolean>`, `export async function destroySession(): Promise<void>`, `export async function getEligibleAccounts(email: string): Promise<{ id: string; name: string }[]>`
  - `middleware.ts`: security headers on every response + cheap cookie-presence redirect for protected paths.
  - The complete login flow (`/login` → `/login/sent` → email → `/auth/verify` → `/dashboard` or `/select-account`, plus `/auth/logout`) that the Playwright e2e task drives.

- [ ] **Step 1 — failing integration test for the session layer.** Create `tests/integration/session.test.ts` with exactly:

```ts
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { prisma } from '../../lib/db'
import { hashToken, verifyMagicLink } from '../../lib/auth/magicLink'
import {
  SESSION_COOKIE,
  destroySession,
  getEligibleAccounts,
  getSession,
  selectAccount,
} from '../../lib/auth/session'

// In-memory stand-in for the Next.js request cookie store.
const { cookieJar } = vi.hoisted(() => ({ cookieJar: new Map<string, string>() }))

vi.mock('next/headers', () => ({
  cookies: () => ({
    get: (name: string) => {
      const value = cookieJar.get(name)
      return value === undefined ? undefined : { name, value }
    },
    set: (name: string, value: string) => {
      cookieJar.set(name, value)
    },
    delete: (name: string) => {
      cookieJar.delete(name)
    },
  }),
}))

const BASE = new Date()
const MIN = 60_000
const DAY = 24 * 60 * MIN

const ACCOUNT_A = '001000000000001AAA'
const ACCOUNT_B = '001000000000002AAA'
const ACCOUNT_C = '001000000000003AAA'
const RAW_LINK_TOKEN = 'f'.repeat(64)

async function resetDb(): Promise<void> {
  await prisma.$transaction([
    prisma.auditLog.deleteMany(),
    prisma.session.deleteMany(),
    prisma.serviceRequest.deleteMany(),
    prisma.magicLink.deleteMany(),
    prisma.portalUser.deleteMany(),
    prisma.orderLineItem.deleteMany(),
    prisma.order.deleteMany(),
    prisma.contact.deleteMany(),
    prisma.account.deleteMany(),
  ])
}

async function seedAccount(id: string, name: string): Promise<void> {
  await prisma.account.create({
    data: { id, name, recordType: 'Business_Account', sfModstamp: BASE },
  })
}

async function seedContact(id: string, accountId: string, email: string, isDeleted = false): Promise<void> {
  await prisma.contact.create({ data: { id, accountId, email, isDeleted, sfModstamp: BASE } })
}

async function createSessionFor(email: string): Promise<{ sessionToken: string; accountIds: string[] }> {
  await prisma.magicLink.create({
    data: {
      email,
      tokenHash: hashToken(RAW_LINK_TOKEN),
      expiresAt: new Date(Date.now() + 15 * MIN),
    },
  })
  const result = await verifyMagicLink(RAW_LINK_TOKEN)
  if (!result) throw new Error('verifyMagicLink failed during test setup')
  return result
}

describe('session lifecycle', () => {
  beforeEach(async () => {
    vi.useFakeTimers({ toFake: ['Date'] })
    vi.setSystemTime(BASE)
    cookieJar.clear()
    await resetDb()
  })

  afterEach(() => {
    vi.useRealTimers()
  })

  afterAll(async () => {
    await prisma.$disconnect()
  })

  it('loads a session created by verifyMagicLink, including the account name', async () => {
    await seedAccount(ACCOUNT_A, 'Acme Retail')
    await seedContact('003000000000001AAA', ACCOUNT_A, 'user@acme.com')
    const { sessionToken } = await createSessionFor('user@acme.com')
    cookieJar.set(SESSION_COOKIE, sessionToken)

    const session = await getSession()

    expect(session).not.toBeNull()
    expect(session?.email).toBe('user@acme.com')
    expect(session?.accountId).toBe(ACCOUNT_A)
    expect(session?.accountName).toBe('Acme Retail')
    expect(session?.userId).toBeTruthy()
  })

  it('returns null with no cookie or an unknown token', async () => {
    expect(await getSession()).toBeNull()
    cookieJar.set(SESSION_COOKIE, 'not-a-real-token')
    expect(await getSession()).toBeNull()
  })

  it('rejects an expired session', async () => {
    await seedAccount(ACCOUNT_A, 'Acme Retail')
    await seedContact('003000000000001AAA', ACCOUNT_A, 'user@acme.com')
    const { sessionToken } = await createSessionFor('user@acme.com')
    cookieJar.set(SESSION_COOKIE, sessionToken)

    vi.setSystemTime(new Date(BASE.getTime() + 31 * DAY))

    expect(await getSession()).toBeNull()
  })

  it('extends a session past its half-life back out to 30 days (rolling)', async () => {
    await seedAccount(ACCOUNT_A, 'Acme Retail')
    await seedContact('003000000000001AAA', ACCOUNT_A, 'user@acme.com')
    const { sessionToken } = await createSessionFor('user@acme.com')
    cookieJar.set(SESSION_COOKIE, sessionToken)

    vi.setSystemTime(new Date(BASE.getTime() + 16 * DAY))
    expect(await getSession()).not.toBeNull()

    // The extension is fire-and-forget; poll the row (real timers untouched —
    // only Date is faked, so setTimeout below sleeps for real).
    const tokenHash = hashToken(sessionToken)
    const expected = BASE.getTime() + 16 * DAY + 30 * DAY
    let extended = false
    for (let i = 0; i < 40 && !extended; i++) {
      const row = await prisma.session.findUniqueOrThrow({ where: { tokenHash } })
      extended = row.expiresAt.getTime() === expected
      if (!extended) await new Promise((resolve) => setTimeout(resolve, 50))
    }
    expect(extended).toBe(true)
  })

  it('selectAccount validates membership, rejects foreign accounts, and audits', async () => {
    await seedAccount(ACCOUNT_A, 'Acme Retail')
    await seedAccount(ACCOUNT_B, 'Beta Builders')
    await seedAccount(ACCOUNT_C, 'Cobra Corp')
    await seedContact('003000000000001AAA', ACCOUNT_A, 'shared@example.com')
    await seedContact('003000000000002AAA', ACCOUNT_B, 'shared@example.com')
    const { sessionToken, accountIds } = await createSessionFor('shared@example.com')
    expect(accountIds).toHaveLength(2)
    cookieJar.set(SESSION_COOKIE, sessionToken)
    expect((await getSession())?.accountId).toBeNull()

    // Foreign account (no contact membership) is rejected.
    expect(await selectAccount(sessionToken, ACCOUNT_C)).toBe(false)
    expect((await getSession())?.accountId).toBeNull()

    // Member account is accepted.
    expect(await selectAccount(sessionToken, ACCOUNT_B)).toBe(true)
    const session = await getSession()
    expect(session?.accountId).toBe(ACCOUNT_B)
    expect(session?.accountName).toBe('Beta Builders')

    const audit = await prisma.auditLog.findFirst({ where: { action: 'account_selected' } })
    expect(audit).not.toBeNull()
    expect(audit?.accountId).toBe(ACCOUNT_B)
    expect(audit?.userId).toBe(session?.userId)
  })

  it('getEligibleAccounts lists distinct non-deleted memberships only', async () => {
    await seedAccount(ACCOUNT_A, 'Acme Retail')
    await seedAccount(ACCOUNT_B, 'Beta Builders')
    await seedAccount(ACCOUNT_C, 'Cobra Corp')
    await seedContact('003000000000001AAA', ACCOUNT_A, 'shared@example.com')
    await seedContact('003000000000002AAA', ACCOUNT_B, 'shared@example.com')
    await seedContact('003000000000003AAA', ACCOUNT_C, 'shared@example.com', true) // deleted → excluded

    const accounts = await getEligibleAccounts('shared@example.com')

    expect(accounts.map((a) => a.id).sort()).toEqual([ACCOUNT_A, ACCOUNT_B].sort())
    expect(accounts.find((a) => a.id === ACCOUNT_A)?.name).toBe('Acme Retail')
  })

  it('destroySession removes the row and the cookie', async () => {
    await seedAccount(ACCOUNT_A, 'Acme Retail')
    await seedContact('003000000000001AAA', ACCOUNT_A, 'user@acme.com')
    const { sessionToken } = await createSessionFor('user@acme.com')
    cookieJar.set(SESSION_COOKIE, sessionToken)

    await destroySession()

    expect(cookieJar.has(SESSION_COOKIE)).toBe(false)
    expect(await prisma.session.count()).toBe(0)
    expect(await getSession()).toBeNull()
  })
})
```

- [ ] **Step 2 — run it, confirm the failure.** With the database up (`docker compose up -d`), run `npm test -- tests/integration/session.test.ts`. Expected failure: `Failed to resolve import "../../lib/auth/session"`.

- [ ] **Step 3 — implement the session layer.** Create `lib/auth/session.ts` with exactly:

```ts
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { prisma } from '../db'
import { logger } from '../logger'
import { hashToken } from './magicLink'

export interface PortalSession {
  userId: string
  email: string
  accountId: string | null
  accountName: string | null
}

export const SESSION_COOKIE = 'sl_session'

const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
const ROLLING_EXTENSION_THRESHOLD_MS = 15 * 24 * 60 * 60 * 1000

export async function getSession(): Promise<PortalSession | null> {
  const rawToken = cookies().get(SESSION_COOKIE)?.value
  if (!rawToken) return null

  const session = await prisma.session.findUnique({
    where: { tokenHash: hashToken(rawToken) },
    include: { user: true },
  })
  const now = new Date()
  if (!session || session.expiresAt < now) return null

  // Rolling extension: past the half-life, push expiry back out to 30 days.
  // Fire-and-forget — a failed extension must never block a page render.
  if (session.expiresAt.getTime() - now.getTime() < ROLLING_EXTENSION_THRESHOLD_MS) {
    prisma.session
      .update({
        where: { id: session.id },
        data: { expiresAt: new Date(now.getTime() + SESSION_TTL_MS) },
      })
      .catch((err: unknown) => logger.warn({ err }, 'session rolling extension failed'))
  }

  let accountName: string | null = null
  if (session.accountId !== null) {
    const account = await prisma.account.findUnique({
      where: { id: session.accountId },
      select: { name: true },
    })
    accountName = account?.name ?? null
  }

  return {
    userId: session.userId,
    email: session.user.email,
    accountId: session.accountId,
    accountName,
  }
}

export async function requireSession(): Promise<PortalSession & { accountId: string }> {
  const session = await getSession()
  if (!session) redirect('/login')
  if (!session.accountId) redirect('/select-account')
  return { ...session, accountId: session.accountId }
}

export async function selectAccount(sessionToken: string, accountId: string): Promise<boolean> {
  const session = await prisma.session.findUnique({
    where: { tokenHash: hashToken(sessionToken) },
    include: { user: true },
  })
  if (!session || session.expiresAt < new Date()) return false

  // Membership check: the session user's email must have a live contact on
  // the requested account. Sourced from the synced contacts table only.
  const membership = await prisma.contact.findFirst({
    where: { email: session.user.email, accountId, isDeleted: false },
  })
  if (!membership) return false

  await prisma.session.update({ where: { id: session.id }, data: { accountId } })
  await prisma.auditLog.create({
    data: { userId: session.userId, accountId, action: 'account_selected' },
  })
  return true
}

export async function destroySession(): Promise<void> {
  const store = cookies()
  const rawToken = store.get(SESSION_COOKIE)?.value
  if (rawToken) {
    await prisma.session.deleteMany({ where: { tokenHash: hashToken(rawToken) } })
  }
  store.delete(SESSION_COOKIE)
}

export async function getEligibleAccounts(email: string): Promise<{ id: string; name: string }[]> {
  const memberships = await prisma.contact.findMany({
    where: { email, isDeleted: false, account: { isDeleted: false } },
    select: { account: { select: { id: true, name: true } } },
    distinct: ['accountId'],
  })
  return memberships.map((m) => ({ id: m.account.id, name: m.account.name }))
}
```

- [ ] **Step 4 — session tests pass.** Run `npm test -- tests/integration/session.test.ts`. Expected: 7 tests PASS.

- [ ] **Step 5 — failing unit test for the middleware.** Create `tests/unit/middleware.test.ts` with exactly:

```ts
import { describe, expect, it } from 'vitest'
import { NextRequest } from 'next/server'
import { middleware } from '../../middleware'

function requestFor(path: string, cookie?: string): NextRequest {
  return new NextRequest(`http://localhost:3000${path}`, {
    headers: cookie === undefined ? {} : { cookie },
  })
}

describe('middleware', () => {
  it('sets security headers on every response', () => {
    const res = middleware(requestFor('/login'))
    expect(res.headers.get('content-security-policy')).toBe(
      "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'",
    )
    expect(res.headers.get('x-content-type-options')).toBe('nosniff')
    expect(res.headers.get('referrer-policy')).toBe('strict-origin-when-cross-origin')
    expect(res.headers.get('x-frame-options')).toBe('DENY')
  })

  it('redirects protected paths to /login when the session cookie is missing', () => {
    for (const path of ['/dashboard', '/sites', '/orders/a001000000000001', '/billing', '/support/new']) {
      const res = middleware(requestFor(path))
      expect(res.status).toBe(307)
      expect(res.headers.get('location')).toBe('http://localhost:3000/login')
      expect(res.headers.get('x-frame-options')).toBe('DENY')
    }
  })

  it('passes protected paths through when the cookie is present', () => {
    const res = middleware(requestFor('/dashboard', 'sl_session=anything'))
    expect(res.status).toBe(200)
    expect(res.headers.get('location')).toBeNull()
  })

  it('does not redirect public paths without a cookie', () => {
    for (const path of ['/', '/login', '/login/sent', '/auth/verify']) {
      const res = middleware(requestFor(path))
      expect(res.status).toBe(200)
    }
  })
})
```

- [ ] **Step 6 — run it, confirm the failure.** Run `npm test -- tests/unit/middleware.test.ts`. Expected failure: `Failed to resolve import "../../middleware"` (or, if Task 1 left a stub, assertion failures on the missing headers).

- [ ] **Step 7 — implement the middleware.** Create (or fully replace) `middleware.ts` at the repo root with exactly:

```ts
import { NextRequest, NextResponse } from 'next/server'

// Keep in sync with SESSION_COOKIE in lib/auth/session.ts. The constant is not
// imported because middleware runs on the Edge runtime and session.ts pulls in
// Prisma, which cannot be bundled there.
const SESSION_COOKIE_NAME = 'sl_session'

const PROTECTED_PREFIXES = ['/dashboard', '/sites', '/orders', '/billing', '/support'] as const

const SECURITY_HEADERS: readonly [string, string][] = [
  [
    'Content-Security-Policy',
    "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'",
  ],
  ['X-Content-Type-Options', 'nosniff'],
  ['Referrer-Policy', 'strict-origin-when-cross-origin'],
  ['X-Frame-Options', 'DENY'],
]

function withSecurityHeaders(response: NextResponse): NextResponse {
  for (const [name, value] of SECURITY_HEADERS) {
    response.headers.set(name, value)
  }
  return response
}

export function middleware(request: NextRequest): NextResponse {
  const { pathname } = request.nextUrl
  const isProtected = PROTECTED_PREFIXES.some(
    (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
  )
  if (isProtected && request.cookies.get(SESSION_COOKIE_NAME) === undefined) {
    // Cheap presence check only — real validation happens in requireSession().
    return withSecurityHeaders(NextResponse.redirect(new URL('/login', request.url)))
  }
  return withSecurityHeaders(NextResponse.next())
}
```

- [ ] **Step 8 — middleware tests pass.** Run `npm test -- tests/unit/middleware.test.ts`. Expected: 4 tests PASS.

- [ ] **Step 9 — failing unit test for the auth route handlers.** Create `tests/unit/authRoutes.test.ts` with exactly:

```ts
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NextRequest } from 'next/server'
import { requestMagicLink, verifyMagicLink } from '../../lib/auth/magicLink'
import { POST as requestLinkPost } from '../../app/api/auth/request-link/route'
import { GET as verifyGet } from '../../app/auth/verify/route'

vi.mock('../../lib/auth/magicLink', () => ({
  requestMagicLink: vi.fn().mockResolvedValue(undefined),
  verifyMagicLink: vi.fn(),
  hashToken: vi.fn((value: string) => value),
}))

function postRequest(body: unknown, forwardedFor?: string): NextRequest {
  return new NextRequest('http://localhost:3000/api/auth/request-link', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      ...(forwardedFor === undefined ? {} : { 'x-forwarded-for': forwardedFor }),
    },
    body: JSON.stringify(body),
  })
}

describe('POST /api/auth/request-link', () => {
  afterEach(() => {
    vi.clearAllMocks()
  })

  it('calls requestMagicLink with the first x-forwarded-for hop and returns ok', async () => {
    const res = await requestLinkPost(postRequest({ email: 'user@example.com' }, '203.0.113.7, 10.0.0.1'))

    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({ ok: true })
    expect(vi.mocked(requestMagicLink)).toHaveBeenCalledWith('user@example.com', '203.0.113.7')
  })

  it('returns ok without calling requestMagicLink on an invalid body (anti-enumeration)', async () => {
    const res = await requestLinkPost(postRequest({ email: 'not-an-email' }))

    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({ ok: true })
    expect(vi.mocked(requestMagicLink)).not.toHaveBeenCalled()
  })

  it('returns ok even when requestMagicLink throws', async () => {
    vi.mocked(requestMagicLink).mockRejectedValueOnce(new Error('db down'))

    const res = await requestLinkPost(postRequest({ email: 'user@example.com' }, '203.0.113.7'))

    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({ ok: true })
  })
})

describe('GET /auth/verify', () => {
  afterEach(() => {
    vi.clearAllMocks()
  })

  it('redirects to /login?error=expired when the token param is missing', async () => {
    const res = await verifyGet(new NextRequest('http://localhost:3000/auth/verify'))

    expect(res.status).toBe(307)
    expect(res.headers.get('location')).toBe('http://localhost:3000/login?error=expired')
    expect(vi.mocked(verifyMagicLink)).not.toHaveBeenCalled()
  })

  it('redirects to /login?error=expired for an invalid token', async () => {
    vi.mocked(verifyMagicLink).mockResolvedValueOnce(null)

    const res = await verifyGet(new NextRequest('http://localhost:3000/auth/verify?token=bad'))

    expect(res.status).toBe(307)
    expect(res.headers.get('location')).toBe('http://localhost:3000/login?error=expired')
  })

  it('sets the session cookie and redirects to /dashboard for a single account', async () => {
    vi.mocked(verifyMagicLink).mockResolvedValueOnce({
      sessionToken: 'tok123',
      accountIds: ['001000000000001AAA'],
    })

    const res = await verifyGet(new NextRequest('http://localhost:3000/auth/verify?token=good'))

    expect(res.headers.get('location')).toBe('http://localhost:3000/dashboard')
    const cookie = res.cookies.get('sl_session')
    expect(cookie?.value).toBe('tok123')
    expect(cookie?.httpOnly).toBe(true)
    expect(cookie?.sameSite).toBe('lax')
    expect(cookie?.path).toBe('/')
    expect(cookie?.maxAge).toBe(30 * 24 * 60 * 60)
  })

  it('redirects to /select-account when the email maps to multiple accounts', async () => {
    vi.mocked(verifyMagicLink).mockResolvedValueOnce({
      sessionToken: 'tok123',
      accountIds: ['001000000000001AAA', '001000000000002AAA'],
    })

    const res = await verifyGet(new NextRequest('http://localhost:3000/auth/verify?token=good'))

    expect(res.headers.get('location')).toBe('http://localhost:3000/select-account')
    expect(res.cookies.get('sl_session')?.value).toBe('tok123')
  })
})
```

- [ ] **Step 10 — run it, confirm the failure.** Run `npm test -- tests/unit/authRoutes.test.ts`. Expected failure: `Failed to resolve import "../../app/api/auth/request-link/route"`.

- [ ] **Step 11 — implement the request-link route.** Create `app/api/auth/request-link/route.ts` with exactly:

```ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { requestMagicLink } from '../../../../lib/auth/magicLink'
import { logger } from '../../../../lib/logger'

const bodySchema = z.object({ email: z.string().email() })

function clientIp(request: NextRequest): string {
  const forwarded = request.headers.get('x-forwarded-for')
  if (forwarded !== null) {
    const firstHop = forwarded.split(',')[0]?.trim()
    if (firstHop) return firstHop
  }
  return request.ip ?? 'unknown'
}

export async function POST(request: NextRequest): Promise<NextResponse> {
  try {
    const body: unknown = await request.json()
    const parsed = bodySchema.safeParse(body)
    if (!parsed.success) {
      logger.info({ issues: parsed.error.issues }, 'magic-link request rejected: invalid body')
    } else {
      await requestMagicLink(parsed.data.email, clientIp(request))
    }
  } catch (err: unknown) {
    logger.error({ err }, 'magic-link request failed')
  }
  // Anti-enumeration: identical response no matter what happened.
  return NextResponse.json({ ok: true })
}
```

- [ ] **Step 12 — implement the verify route.** Create `app/auth/verify/route.ts` with exactly:

```ts
import { NextRequest, NextResponse } from 'next/server'
import { verifyMagicLink } from '../../../lib/auth/magicLink'
import { SESSION_COOKIE } from '../../../lib/auth/session'

const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60

export async function GET(request: NextRequest): Promise<NextResponse> {
  const token = request.nextUrl.searchParams.get('token')
  if (!token) {
    return NextResponse.redirect(new URL('/login?error=expired', request.url))
  }

  const result = await verifyMagicLink(token)
  if (!result) {
    return NextResponse.redirect(new URL('/login?error=expired', request.url))
  }

  const destination = result.accountIds.length === 1 ? '/dashboard' : '/select-account'
  const response = NextResponse.redirect(new URL(destination, request.url))
  response.cookies.set(SESSION_COOKIE, result.sessionToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
    maxAge: SESSION_MAX_AGE_SECONDS,
  })
  return response
}
```

- [ ] **Step 13 — route tests pass.** Run `npm test -- tests/unit/authRoutes.test.ts`. Expected: 7 tests PASS.

- [ ] **Step 14 — login page + client form.** Create `app/login/LoginForm.tsx` with exactly:

```tsx
'use client'

import { useRouter } from 'next/navigation'
import { type FormEvent, useState } from 'react'

export default function LoginForm(): JSX.Element {
  const router = useRouter()
  const [email, setEmail] = useState('')
  const [submitting, setSubmitting] = useState(false)

  async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
    event.preventDefault()
    if (submitting) return
    setSubmitting(true)
    try {
      await fetch('/api/auth/request-link', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email }),
      })
    } catch {
      // Anti-enumeration: the sent page is shown regardless of outcome.
    }
    router.push('/login/sent')
  }

  return (
    <form onSubmit={handleSubmit} className="flex flex-col gap-4">
      <label htmlFor="email" className="text-sm font-medium text-gray-700">
        Email address
      </label>
      <input
        id="email"
        type="email"
        required
        autoComplete="email"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        placeholder="you@company.com"
        className="rounded border border-gray-300 px-3 py-2 focus:border-gray-500 focus:outline-none"
      />
      <button
        type="submit"
        disabled={submitting}
        className="rounded bg-gray-900 px-4 py-2 font-medium text-white hover:bg-gray-700 disabled:opacity-50"
      >
        {submitting ? 'Sending…' : 'Email me a sign-in link'}
      </button>
    </form>
  )
}
```

Then create `app/login/page.tsx` with exactly:

```tsx
import type { Metadata } from 'next'
import LoginForm from './LoginForm'

export const metadata: Metadata = { title: 'Sign in — Streamlink' }

export default function LoginPage({
  searchParams,
}: {
  searchParams: { error?: string }
}): JSX.Element {
  return (
    <main className="mx-auto flex min-h-screen max-w-md flex-col justify-center gap-6 p-6">
      <h1 className="text-2xl font-semibold">Sign in to Streamlink</h1>
      <p className="text-sm text-gray-600">
        Enter the email address you use with LDR Site Services and we&apos;ll send you a one-time
        sign-in link.
      </p>
      {searchParams.error === 'expired' && (
        <p className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
          That sign-in link has expired or was already used. Request a new one below.
        </p>
      )}
      <LoginForm />
    </main>
  )
}
```

- [ ] **Step 15 — “check your email” page.** Create `app/login/sent/page.tsx` with exactly:

```tsx
import type { Metadata } from 'next'

export const metadata: Metadata = { title: 'Check your email — Streamlink' }

export default function LoginSentPage(): JSX.Element {
  return (
    <main className="mx-auto flex min-h-screen max-w-md flex-col justify-center gap-4 p-6 text-center">
      <h1 className="text-2xl font-semibold">Check your email</h1>
      <p className="text-sm text-gray-600">
        If that address is on file with LDR Site Services, a sign-in link is on its way. The link
        expires in 15 minutes and can be used once.
      </p>
      <p className="text-sm text-gray-600">
        Didn&apos;t get it? Check your spam folder, or{' '}
        <a href="/login" className="underline">
          try again
        </a>
        .
      </p>
    </main>
  )
}
```

- [ ] **Step 16 — account picker page with server action.** Create `app/select-account/page.tsx` with exactly:

```tsx
import type { Metadata } from 'next'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import {
  SESSION_COOKIE,
  getEligibleAccounts,
  getSession,
  selectAccount,
} from '../../lib/auth/session'

export const metadata: Metadata = { title: 'Choose an account — Streamlink' }

export default async function SelectAccountPage(): Promise<JSX.Element> {
  const session = await getSession()
  if (!session) redirect('/login')
  const accounts = await getEligibleAccounts(session.email)

  async function chooseAccount(formData: FormData): Promise<void> {
    'use server'
    const accountId = formData.get('accountId')
    const rawToken = cookies().get(SESSION_COOKIE)?.value
    if (typeof accountId === 'string' && rawToken && (await selectAccount(rawToken, accountId))) {
      redirect('/dashboard')
    }
    redirect('/select-account')
  }

  return (
    <main className="mx-auto flex min-h-screen max-w-md flex-col justify-center gap-6 p-6">
      <h1 className="text-2xl font-semibold">Choose an account</h1>
      <p className="text-sm text-gray-600">
        Your email is linked to more than one account. Pick one to continue — you can switch later
        from the header.
      </p>
      {accounts.length === 0 ? (
        <p className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
          No accounts are currently linked to your email. Contact LDR Site Services support.
        </p>
      ) : (
        <form action={chooseAccount} className="flex flex-col gap-3">
          {accounts.map((account) => (
            <button
              key={account.id}
              type="submit"
              name="accountId"
              value={account.id}
              className="rounded border border-gray-300 px-4 py-3 text-left hover:bg-gray-50"
            >
              {account.name}
            </button>
          ))}
        </form>
      )}
    </main>
  )
}
```

- [ ] **Step 17 — logout route.** Create `app/auth/logout/route.ts` with exactly:

```ts
import { NextRequest, NextResponse } from 'next/server'
import { destroySession } from '../../../lib/auth/session'

export async function POST(request: NextRequest): Promise<NextResponse> {
  await destroySession()
  return NextResponse.redirect(new URL('/login', request.url), 303)
}
```

- [ ] **Step 18 — typecheck and full suite.** Run `npx tsc --noEmit` — expected: exit 0, no errors (this is the compile gate for the pages/routes, which have no unit tests of their own beyond Step 9; the Playwright e2e task drives them in a browser). Then, with the database up, run `npm test` — expected: all suites PASS.

- [ ] **Step 19 — commit.**

```sh
git add lib/auth/session.ts middleware.ts app/login app/select-account app/auth app/api/auth tests/unit/middleware.test.ts tests/unit/authRoutes.test.ts tests/integration/session.test.ts
git commit -m "feat: session management, security middleware, and login flow"
```
### Task 12: Domain — dashboard + orders

**Files:**
- Create: `lib/domain/orders.ts`
- Create: `lib/domain/dashboard.ts`
- Test: `tests/unit/site-key.test.ts`
- Test: `tests/integration/domain-orders.test.ts`
- Test: `tests/integration/domain-dashboard.test.ts`

**Interfaces:**

Consumes (from earlier tasks):
- `export const prisma: PrismaClient` from `lib/db.ts` (Prisma singleton).
- `export type UnitStatus = 'scheduled' | 'delivered' | 'removal_requested' | 'completed' | 'cancelled'` from `lib/mapping/status.ts`.
- Prisma models `Account`, `Order`, `OrderLineItem`, `Transaction`, `DeliveryConfirmation`, `ServiceRequest`, `PortalUser` from `prisma/schema.prisma` (Task 2). Note: `Order.customerStatus` holds `'open' | 'closed'` and `OrderLineItem.customerStatus` holds a `UnitStatus` string — both are written by the sync mappers; `OrderLineItem.isChargeAdjustment` marks charge-adjustment OLIs.
- Local Postgres from Task 2's docker-compose (port 5433, database `streamlink`) — required by the integration tests only. The unit test must not touch the DB.

Produces (relied on by Task 13 pages and Task 17 e2e) — exactly the plan contract:

```ts
// lib/domain/orders.ts
export interface SiteGroup { siteKey: string; siteLabel: string; orders: OrderSummary[] }
export interface OrderSummary { id: string; orderNumber: string; customerStatus: 'open' | 'closed'; siteLabel: string; deliveryDate: Date | null; balance: string | null; activeUnitCount: number }
export interface UnitDetail { id: string; jobType: string | null; product: string | null; size: string | null; quantity: number | null; status: UnitStatus; deliveryDate: Date | null; endDate: Date | null; lineTotal: string | null; adjustments: { recordType: string; lineTotal: string | null; description: string | null }[] }
export interface OrderDetail extends OrderSummary { units: UnitDetail[]; unattachedAdjustments: { recordType: string; lineTotal: string | null; description: string | null }[]; customerPo: string | null; notesToCustomer: string | null; totals: { total: string | null; tax: string | null; charged: string | null; balance: string | null } }
export function siteKeyFor(o: { storeName: string | null; storeNumber: string | null; siteAddress: string | null; shippingStreet: string | null; shippingCity: string | null; shippingState: string | null; shippingZip: string | null }): string
export async function getSites(accountId: string): Promise<SiteGroup[]>
export async function getOrderDetail(accountId: string, orderId: string): Promise<OrderDetail | null>

// lib/domain/dashboard.ts
export interface DashboardData { activeUnits: number; sitesWithActiveUnits: number; openBalance: string; recentActivity: { at: Date; kind: 'delivery' | 'payment' | 'request'; text: string }[] }
export async function getDashboard(accountId: string): Promise<DashboardData>
```

All money leaves these functions as `.toFixed(2)` strings (Prisma `Decimal` → `'1200.50'`). Both files read portal Postgres only — no Salesforce imports. Every query filters by `accountId`; `getOrderDetail` uses `findFirst({ where: { id, accountId, ... } })` and must NEVER use `findUnique` by id alone.

**Steps:**

- [ ] Write the failing unit test for `siteKeyFor` at `tests/unit/site-key.test.ts`:

```ts
import { describe, expect, it } from 'vitest'
import { siteKeyFor } from '../../lib/domain/orders'

const base = {
  storeName: null,
  storeNumber: null,
  siteAddress: null,
  shippingStreet: null,
  shippingCity: null,
  shippingState: null,
  shippingZip: null,
}

describe('siteKeyFor', () => {
  it('uses store name + number, uppercased with collapsed whitespace', () => {
    expect(siteKeyFor({ ...base, storeName: 'Target', storeNumber: '1234' })).toBe('TARGET #1234')
  })

  it('uses store name alone when store number is null', () => {
    expect(siteKeyFor({ ...base, storeName: 'Rubicon' })).toBe('RUBICON')
  })

  it('collapses and trims whitespace so near-duplicate stores share a key', () => {
    expect(siteKeyFor({ ...base, storeName: '  Target   Corp ', storeNumber: ' 88 ' })).toBe('TARGET CORP #88')
    expect(siteKeyFor({ ...base, storeName: 'Target Corp', storeNumber: '88' })).toBe('TARGET CORP #88')
  })

  it('falls back to normalized site address when there is no store name', () => {
    expect(siteKeyFor({ ...base, siteAddress: ' 123  Main St,  Springfield ' })).toBe('123 MAIN ST, SPRINGFIELD')
  })

  it('falls back to shipping fields joined with commas, skipping nulls', () => {
    expect(
      siteKeyFor({ ...base, shippingStreet: '9 Elm St', shippingCity: 'Denver', shippingState: 'CO' }),
    ).toBe('9 ELM ST, DENVER, CO')
  })

  it('returns __OTHER__ when no site data exists', () => {
    expect(siteKeyFor(base)).toBe('__OTHER__')
  })

  it('treats whitespace-only strings as missing', () => {
    expect(siteKeyFor({ ...base, storeName: '   ', siteAddress: '456 Oak Ave' })).toBe('456 OAK AVE')
  })
})
```

- [ ] Run it and confirm the failure: `npx vitest run tests/unit/site-key.test.ts` — expected: the suite errors with a module-resolution failure (`Failed to resolve import "../../lib/domain/orders"` / `Cannot find module`), because `lib/domain/orders.ts` does not exist yet.

- [ ] Write the failing integration test for the orders domain at `tests/integration/domain-orders.test.ts`. It sets `DATABASE_URL` (defaulting to the Task 2 docker DB) *before* dynamically importing `lib/db`, seeds two accounts directly through Prisma, and covers: cross-account isolation (negative test), store-vs-address grouping, site/order sorting, active-unit counting, phantom-dumpster adjustment attachment, and `.toFixed(2)` money strings.

```ts
import { afterAll, beforeAll, describe, expect, it } from 'vitest'

// Must be set before lib/db is loaded, hence the dynamic imports below.
process.env.DATABASE_URL ??= 'postgresql://streamlink:streamlink@localhost:5433/streamlink'

const { prisma } = await import('../../lib/db')
const { getOrderDetail, getSites } = await import('../../lib/domain/orders')

const A = 't12o-acct-a'
const B = 't12o-acct-b'
const MOD = new Date('2026-07-16T00:00:00Z')

async function cleanup(): Promise<void> {
  await prisma.orderLineItem.deleteMany({ where: { order: { accountId: { in: [A, B] } } } })
  await prisma.order.deleteMany({ where: { accountId: { in: [A, B] } } })
  await prisma.account.deleteMany({ where: { id: { in: [A, B] } } })
}

beforeAll(async () => {
  await cleanup()
  await prisma.account.createMany({
    data: [
      { id: A, name: 'Acme Co', recordType: 'Business_Account', sfModstamp: MOD },
      { id: B, name: 'Rival Co', recordType: 'Business_Account', sfModstamp: MOD },
    ],
  })
  await prisma.order.createMany({
    data: [
      // Store-named site (note trailing space — must normalize into the same group as o2)
      { id: 't12o-o1', accountId: A, orderNumber: 'CO-1001', customerStatus: 'open', storeName: 'Target ', storeNumber: '1234', deliveryDate: new Date('2026-06-01T00:00:00Z'), balance: '1200.5', totalAfterTax: '1500', taxAmount: '112.53', chargedAmount: '300', customerPo: 'PO-77', notesToCustomer: 'Gate code 4321', sfModstamp: MOD },
      { id: 't12o-o2', accountId: A, orderNumber: 'CO-1002', customerStatus: 'closed', storeName: 'Target', storeNumber: '1234', deliveryDate: null, balance: null, sfModstamp: MOD },
      // Site-address fallback (messy whitespace — must normalize)
      { id: 't12o-o3', accountId: A, orderNumber: 'CO-1003', customerStatus: 'open', siteAddress: ' 123  Main St, Springfield ', deliveryDate: new Date('2026-05-01T00:00:00Z'), balance: '0', sfModstamp: MOD },
      // Shipping-fields fallback (zip null — must be skipped in the join)
      { id: 't12o-o4', accountId: A, orderNumber: 'CO-1004', customerStatus: 'closed', shippingStreet: '9 Elm St', shippingCity: 'Denver', shippingState: 'CO', deliveryDate: new Date('2026-01-15T00:00:00Z'), sfModstamp: MOD },
      // No site data at all → __OTHER__
      { id: 't12o-o5', accountId: A, orderNumber: 'CO-1005', customerStatus: 'closed', sfModstamp: MOD },
      // Soft-deleted order — must never appear
      { id: 't12o-del', accountId: A, orderNumber: 'CO-1006', customerStatus: 'open', isDeleted: true, storeName: 'Target', storeNumber: '1234', sfModstamp: MOD },
      // Account B order sharing the exact same site key as A's Target site
      { id: 't12o-b1', accountId: B, orderNumber: 'CO-2001', customerStatus: 'open', storeName: 'Target', storeNumber: '1234', balance: '999', sfModstamp: MOD },
    ],
  })
  await prisma.orderLineItem.createMany({
    data: [
      // o1: one placement + two charge adjustments (phantom-dumpster case 1)
      { id: 't12o-o1-p1', orderId: 't12o-o1', recordType: 'OrderLineItem', isChargeAdjustment: false, jobType: 'Dumpster', product: '20 Yard Roll Off', size: '20', quantity: 1, customerStatus: 'delivered', deliveryDate: new Date('2026-06-01T00:00:00Z'), endDate: new Date('2026-06-15T00:00:00Z'), lineTotal: '350.25', sfModstamp: MOD },
      { id: 't12o-o1-a1', orderId: 't12o-o1', recordType: 'Additional Tonnage', isChargeAdjustment: true, customerStatus: 'completed', lineTotal: '95.5', notesToCustomer: 'Extra 2 tons over allowance', sfModstamp: MOD },
      { id: 't12o-o1-a2', orderId: 't12o-o1', recordType: 'Extended Rental', isChargeAdjustment: true, customerStatus: 'completed', lineTotal: '45', sfModstamp: MOD },
      // o2: one completed placement (inactive) + one soft-deleted active placement (excluded)
      { id: 't12o-o2-p1', orderId: 't12o-o2', isChargeAdjustment: false, jobType: 'Dumpster', customerStatus: 'completed', sfModstamp: MOD },
      { id: 't12o-o2-del', orderId: 't12o-o2', isChargeAdjustment: false, jobType: 'Dumpster', customerStatus: 'delivered', isDeleted: true, sfModstamp: MOD },
      // o3: two placements + one charge adjustment (phantom-dumpster case 2)
      { id: 't12o-o3-p1', orderId: 't12o-o3', isChargeAdjustment: false, jobType: 'Porta Potty', product: 'Standard Unit', customerStatus: 'scheduled', deliveryDate: new Date('2026-05-10T00:00:00Z'), sfModstamp: MOD },
      { id: 't12o-o3-p2', orderId: 't12o-o3', isChargeAdjustment: false, jobType: 'Porta Potty', product: 'Deluxe Unit', customerStatus: 'delivered', deliveryDate: new Date('2026-05-03T00:00:00Z'), sfModstamp: MOD },
      { id: 't12o-o3-a1', orderId: 't12o-o3', recordType: 'Other Charges', isChargeAdjustment: true, customerStatus: 'completed', lineTotal: '30', product: 'Cleaning fee', sfModstamp: MOD },
      // o4: cancelled placement — inactive site
      { id: 't12o-o4-p1', orderId: 't12o-o4', isChargeAdjustment: false, jobType: 'Fencing', customerStatus: 'cancelled', cancelReason: 'Customer cancelled', sfModstamp: MOD },
      // Account B active placement
      { id: 't12o-b1-p1', orderId: 't12o-b1', isChargeAdjustment: false, jobType: 'Dumpster', customerStatus: 'delivered', sfModstamp: MOD },
    ],
  })
})

afterAll(async () => {
  await cleanup()
  await prisma.$disconnect()
})

describe('getSites', () => {
  it("never returns another account's orders (cross-account isolation)", async () => {
    const sites = await getSites(A)
    const allOrderIds = sites.flatMap((s) => s.orders.map((o) => o.id))
    expect(allOrderIds).not.toContain('t12o-b1')
    expect(allOrderIds).not.toContain('t12o-del')
    // B's Target order shares the exact same siteKey — it must still be invisible to A
    const target = sites.find((s) => s.siteKey === 'TARGET #1234')
    expect(target?.orders.map((o) => o.id)).toEqual(['t12o-o1', 't12o-o2'])
    // and the reverse direction
    const bSites = await getSites(B)
    expect(bSites.flatMap((s) => s.orders.map((o) => o.id))).toEqual(['t12o-b1'])
  })

  it('groups by store name/number and falls back to site address, shipping fields, then Other', async () => {
    const sites = await getSites(A)
    expect(sites.map((s) => s.siteKey)).toEqual([
      '123 MAIN ST, SPRINGFIELD', // active, label sorts before Target
      'TARGET #1234',             // active
      '9 ELM ST, DENVER, CO',     // inactive
      '__OTHER__',                // inactive
    ])
    expect(sites.map((s) => s.siteLabel)).toEqual([
      '123 Main St, Springfield',
      'Target #1234',
      '9 Elm St, Denver, CO',
      'Other locations',
    ])
  })

  it('sorts orders within a site by delivery date desc with nulls last', async () => {
    const sites = await getSites(A)
    const target = sites.find((s) => s.siteKey === 'TARGET #1234')
    expect(target?.orders.map((o) => o.id)).toEqual(['t12o-o1', 't12o-o2']) // o2 has null deliveryDate
    expect(target?.orders[0]?.deliveryDate?.toISOString()).toBe('2026-06-01T00:00:00.000Z')
    expect(target?.orders[1]?.deliveryDate).toBeNull()
  })

  it('counts only active, non-adjustment, non-deleted units', async () => {
    const sites = await getSites(A)
    const byId = new Map(sites.flatMap((s) => s.orders).map((o) => [o.id, o]))
    expect(byId.get('t12o-o1')?.activeUnitCount).toBe(1) // adjustments not counted
    expect(byId.get('t12o-o2')?.activeUnitCount).toBe(0) // completed + deleted don't count
    expect(byId.get('t12o-o3')?.activeUnitCount).toBe(2) // scheduled + delivered
    expect(byId.get('t12o-o4')?.activeUnitCount).toBe(0) // cancelled doesn't count
  })

  it('returns balances as fixed two-decimal strings or null', async () => {
    const sites = await getSites(A)
    const byId = new Map(sites.flatMap((s) => s.orders).map((o) => [o.id, o]))
    expect(byId.get('t12o-o1')?.balance).toBe('1200.50')
    expect(byId.get('t12o-o2')?.balance).toBeNull()
    expect(byId.get('t12o-o3')?.balance).toBe('0.00')
  })
})

describe('getOrderDetail', () => {
  it("returns null for another account's order (cross-account negative test)", async () => {
    expect(await getOrderDetail(A, 't12o-b1')).toBeNull()
    expect(await getOrderDetail(B, 't12o-o1')).toBeNull()
  })

  it('returns null for soft-deleted and unknown orders', async () => {
    expect(await getOrderDetail(A, 't12o-del')).toBeNull()
    expect(await getOrderDetail(A, 'does-not-exist')).toBeNull()
  })

  it('attaches all adjustments to a single placement unit — no phantom dumpsters', async () => {
    const detail = await getOrderDetail(A, 't12o-o1')
    expect(detail).not.toBeNull()
    expect(detail?.units).toHaveLength(1)
    expect(detail?.units[0]?.id).toBe('t12o-o1-p1')
    expect(detail?.units[0]?.status).toBe('delivered')
    expect(detail?.units[0]?.adjustments).toEqual([
      { recordType: 'Additional Tonnage', lineTotal: '95.50', description: 'Extra 2 tons over allowance' },
      { recordType: 'Extended Rental', lineTotal: '45.00', description: 'Extended Rental' },
    ])
    expect(detail?.unattachedAdjustments).toEqual([])
  })

  it('leaves adjustments unattached when there are multiple placements; units sorted by delivery date asc', async () => {
    const detail = await getOrderDetail(A, 't12o-o3')
    expect(detail?.units.map((u) => u.id)).toEqual(['t12o-o3-p2', 't12o-o3-p1']) // 05-03 before 05-10
    expect(detail?.units.every((u) => u.adjustments.length === 0)).toBe(true)
    expect(detail?.unattachedAdjustments).toEqual([
      { recordType: 'Other Charges', lineTotal: '30.00', description: 'Cleaning fee' },
    ])
  })

  it('returns money as fixed strings and passes through header fields', async () => {
    const detail = await getOrderDetail(A, 't12o-o1')
    expect(detail?.totals).toEqual({ total: '1500.00', tax: '112.53', charged: '300.00', balance: '1200.50' })
    expect(detail?.units[0]?.lineTotal).toBe('350.25')
    expect(detail?.customerPo).toBe('PO-77')
    expect(detail?.notesToCustomer).toBe('Gate code 4321')
    expect(detail?.customerStatus).toBe('open')
    expect(detail?.siteLabel).toBe('Target #1234')
    expect(detail?.activeUnitCount).toBe(1)
  })
})
```

- [ ] Write the failing integration test for the dashboard at `tests/integration/domain-dashboard.test.ts`:

```ts
import { afterAll, beforeAll, describe, expect, it } from 'vitest'

process.env.DATABASE_URL ??= 'postgresql://streamlink:streamlink@localhost:5433/streamlink'

const { prisma } = await import('../../lib/db')
const { getDashboard } = await import('../../lib/domain/dashboard')

const A = 't12d-acct-a'
const B = 't12d-acct-b'
const USER = 't12d-user'
const MOD = new Date('2026-07-16T00:00:00Z')

async function cleanup(): Promise<void> {
  await prisma.serviceRequest.deleteMany({ where: { accountId: { in: [A, B] } } })
  await prisma.portalUser.deleteMany({ where: { id: USER } })
  await prisma.deliveryConfirmation.deleteMany({ where: { orderId: { startsWith: 't12d-o' } } })
  await prisma.transaction.deleteMany({ where: { accountId: { in: [A, B] } } })
  await prisma.orderLineItem.deleteMany({ where: { order: { accountId: { in: [A, B] } } } })
  await prisma.order.deleteMany({ where: { accountId: { in: [A, B] } } })
  await prisma.account.deleteMany({ where: { id: { in: [A, B] } } })
}

beforeAll(async () => {
  await cleanup()
  await prisma.account.createMany({
    data: [
      { id: A, name: 'Depot Co', recordType: 'Business_Account', sfModstamp: MOD },
      { id: B, name: 'Rival Co', recordType: 'Business_Account', sfModstamp: MOD },
    ],
  })
  await prisma.order.createMany({
    data: [
      { id: 't12d-o1', accountId: A, orderNumber: 'DO-1', customerStatus: 'open', storeName: 'Depot', storeNumber: '77', balance: '100.25', sfModstamp: MOD },
      { id: 't12d-o2', accountId: A, orderNumber: 'DO-2', customerStatus: 'closed', siteAddress: 'Yard B', balance: '-50', sfModstamp: MOD },
      { id: 't12d-o3', accountId: A, orderNumber: 'DO-3', customerStatus: 'open', siteAddress: 'Yard C', balance: '24.75', sfModstamp: MOD },
      { id: 't12d-o4del', accountId: A, orderNumber: 'DO-4', customerStatus: 'open', balance: '500', isDeleted: true, sfModstamp: MOD },
      { id: 't12d-ob1', accountId: B, orderNumber: 'DO-9', customerStatus: 'open', storeName: 'Depot', storeNumber: '99', balance: '9999', sfModstamp: MOD },
    ],
  })
  await prisma.orderLineItem.createMany({
    data: [
      { id: 't12d-o1-p1', orderId: 't12d-o1', isChargeAdjustment: false, jobType: 'Dumpster', customerStatus: 'scheduled', sfModstamp: MOD },
      { id: 't12d-o1-p2', orderId: 't12d-o1', isChargeAdjustment: false, jobType: 'Dumpster', customerStatus: 'removal_requested', sfModstamp: MOD },
      { id: 't12d-o1-p3', orderId: 't12d-o1', isChargeAdjustment: false, jobType: 'Dumpster', customerStatus: 'completed', sfModstamp: MOD },
      // adjustment with an "active-looking" status must NOT count as a unit
      { id: 't12d-o1-a1', orderId: 't12d-o1', recordType: 'Additional Tonnage', isChargeAdjustment: true, customerStatus: 'delivered', sfModstamp: MOD },
      { id: 't12d-o2-p1', orderId: 't12d-o2', isChargeAdjustment: false, jobType: 'Dumpster', customerStatus: 'completed', sfModstamp: MOD },
      { id: 't12d-ob1-p1', orderId: 't12d-ob1', isChargeAdjustment: false, jobType: 'Dumpster', customerStatus: 'delivered', sfModstamp: MOD },
    ],
  })
  await prisma.deliveryConfirmation.createMany({
    data: [
      { id: 't12d-dc1', orderId: 't12d-o1', isResponded: true, respondedOn: new Date('2026-07-10T10:00:00Z'), response: 'yes', sfModstamp: MOD },
      { id: 't12d-dc2', orderId: 't12d-o1', isResponded: false, respondedOn: null, sfModstamp: MOD }, // unanswered → excluded
      { id: 't12d-dcb', orderId: 't12d-ob1', isResponded: true, respondedOn: new Date('2026-07-15T10:00:00Z'), response: 'yes', sfModstamp: MOD }, // B's → excluded
    ],
  })
  await prisma.transaction.createMany({
    data: [
      { id: 't12d-t1', name: 'T-1', accountId: A, orderId: 't12d-o1', countsTowardTotals: true, amount: '250', sfCreatedAt: new Date('2026-07-12T09:00:00Z'), sfModstamp: MOD },
      { id: 't12d-t2', name: 'T-2', accountId: A, countsTowardTotals: false, isVoided: true, amount: '99', sfCreatedAt: new Date('2026-07-14T09:00:00Z'), sfModstamp: MOD }, // voided → excluded
      { id: 't12d-t3', name: 'T-3', accountId: A, orderId: null, countsTowardTotals: true, amount: '10', sfCreatedAt: new Date('2026-07-08T00:00:00Z'), sfModstamp: MOD },
      { id: 't12d-tb', name: 'T-9', accountId: B, countsTowardTotals: true, amount: '777', sfCreatedAt: new Date('2026-07-15T09:00:00Z'), sfModstamp: MOD }, // B's → excluded
      // 8 older payments so the merged feed exceeds 10 items
      ...Array.from({ length: 8 }, (_, i) => ({
        id: `t12d-old-${i}`,
        name: `T-OLD-${i}`,
        accountId: A,
        countsTowardTotals: true,
        amount: '1',
        sfCreatedAt: new Date(`2026-06-0${i + 1}T00:00:00Z`),
        sfModstamp: MOD,
      })),
    ],
  })
  await prisma.portalUser.create({ data: { id: USER, email: 't12d-user@example.com' } })
  await prisma.serviceRequest.create({
    data: {
      userId: USER,
      accountId: A,
      type: 'Swap Dumpster',
      description: 'Please swap',
      createdAt: new Date('2026-07-11T12:00:00Z'),
    },
  })
})

afterAll(async () => {
  await cleanup()
  await prisma.$disconnect()
})

describe('getDashboard', () => {
  it('counts active units and sites with active units, excluding adjustments and other accounts', async () => {
    const data = await getDashboard(A)
    expect(data.activeUnits).toBe(2) // scheduled + removal_requested; not completed, not the adjustment, not B's
    expect(data.sitesWithActiveUnits).toBe(1) // only Depot #77
  })

  it('sums only positive balances on non-deleted orders, as a fixed string', async () => {
    const data = await getDashboard(A)
    expect(data.openBalance).toBe('125.00') // 100.25 + 24.75; -50 credit and deleted 500 excluded
  })

  it('merges deliveries, payments, and requests, sorted desc, capped at 10', async () => {
    const data = await getDashboard(A)
    expect(data.recentActivity).toHaveLength(10) // 12 candidates → 10
    expect(data.recentActivity.slice(0, 4)).toEqual([
      { at: new Date('2026-07-12T09:00:00Z'), kind: 'payment', text: 'Payment $250.00 — Order DO-1' },
      { at: new Date('2026-07-11T12:00:00Z'), kind: 'request', text: 'Swap Dumpster request submitted' },
      { at: new Date('2026-07-10T10:00:00Z'), kind: 'delivery', text: 'Delivery confirmed — Order DO-1' },
      { at: new Date('2026-07-08T00:00:00Z'), kind: 'payment', text: 'Payment $10.00' },
    ])
    // the two oldest June payments fall off the end
    expect(data.recentActivity[9]?.at.toISOString()).toBe('2026-06-03T00:00:00.000Z')
  })

  it("never includes account B's activity (cross-account negative test)", async () => {
    const data = await getDashboard(A)
    for (const item of data.recentActivity) {
      expect(item.text).not.toContain('DO-9')
      expect(item.text).not.toContain('777')
    }
    const b = await getDashboard(B)
    expect(b.activeUnits).toBe(1)
    expect(b.sitesWithActiveUnits).toBe(1)
    expect(b.openBalance).toBe('9999.00')
  })
})
```

- [ ] Run the integration tests and confirm they fail for the right reason. Precondition: the Task 2 database is up and the schema is applied — `docker compose up -d` then `npx prisma db push` (idempotent against the local dev DB). Command: `npx vitest run tests/integration/domain-orders.test.ts tests/integration/domain-dashboard.test.ts` — expected: both suites error with unresolved imports of `../../lib/domain/orders` and `../../lib/domain/dashboard`.

- [ ] Create `lib/domain/orders.ts` — complete file:

```ts
import { Prisma } from '@prisma/client'
import { prisma } from '../db'
import type { UnitStatus } from '../mapping/status'

export interface SiteGroup {
  siteKey: string
  siteLabel: string
  orders: OrderSummary[]
}

export interface OrderSummary {
  id: string
  orderNumber: string
  customerStatus: 'open' | 'closed'
  siteLabel: string
  deliveryDate: Date | null
  balance: string | null
  activeUnitCount: number
}

export interface UnitDetail {
  id: string
  jobType: string | null
  product: string | null
  size: string | null
  quantity: number | null
  status: UnitStatus
  deliveryDate: Date | null
  endDate: Date | null
  lineTotal: string | null
  adjustments: { recordType: string; lineTotal: string | null; description: string | null }[]
}

export interface OrderDetail extends OrderSummary {
  units: UnitDetail[]
  unattachedAdjustments: { recordType: string; lineTotal: string | null; description: string | null }[]
  customerPo: string | null
  notesToCustomer: string | null
  totals: { total: string | null; tax: string | null; charged: string | null; balance: string | null }
}

type SiteFields = {
  storeName: string | null
  storeNumber: string | null
  siteAddress: string | null
  shippingStreet: string | null
  shippingCity: string | null
  shippingState: string | null
  shippingZip: string | null
}

const UNIT_STATUSES: readonly UnitStatus[] = ['scheduled', 'delivered', 'removal_requested', 'completed', 'cancelled']
const ACTIVE_UNIT_STATUSES: readonly UnitStatus[] = ['scheduled', 'delivered', 'removal_requested']

function asUnitStatus(raw: string): UnitStatus {
  // Sync mappers only ever write UnitStatus values; treat anything unexpected as inert.
  return (UNIT_STATUSES as readonly string[]).includes(raw) ? (raw as UnitStatus) : 'completed'
}

function asOrderStatus(raw: string): 'open' | 'closed' {
  return raw === 'open' ? 'open' : 'closed'
}

function isActiveUnit(li: { isChargeAdjustment: boolean; customerStatus: string }): boolean {
  return !li.isChargeAdjustment && (ACTIVE_UNIT_STATUSES as readonly string[]).includes(li.customerStatus)
}

function money(value: Prisma.Decimal | null): string | null {
  return value === null ? null : value.toFixed(2)
}

function normalizeWhitespace(value: string): string {
  return value.trim().replace(/\s+/g, ' ')
}

function siteInfoFor(o: SiteFields): { key: string; label: string } {
  const storeName = o.storeName ? normalizeWhitespace(o.storeName) : ''
  if (storeName !== '') {
    const storeNumber = o.storeNumber ? normalizeWhitespace(o.storeNumber) : ''
    const label = storeNumber !== '' ? `${storeName} #${storeNumber}` : storeName
    return { key: label.toUpperCase(), label }
  }
  const siteAddress = o.siteAddress ? normalizeWhitespace(o.siteAddress) : ''
  if (siteAddress !== '') {
    return { key: siteAddress.toUpperCase(), label: siteAddress }
  }
  const shippingParts = [o.shippingStreet, o.shippingCity, o.shippingState, o.shippingZip]
    .map((part) => (part ? normalizeWhitespace(part) : ''))
    .filter((part) => part !== '')
  if (shippingParts.length > 0) {
    const label = shippingParts.join(', ')
    return { key: label.toUpperCase(), label }
  }
  return { key: '__OTHER__', label: 'Other locations' }
}

export function siteKeyFor(o: SiteFields): string {
  return siteInfoFor(o).key
}

export async function getSites(accountId: string): Promise<SiteGroup[]> {
  const orders = await prisma.order.findMany({
    where: { accountId, isDeleted: false },
    include: { lineItems: { where: { isDeleted: false } } },
    orderBy: { deliveryDate: { sort: 'desc', nulls: 'last' } },
  })

  const groups = new Map<string, SiteGroup>()
  for (const order of orders) {
    const { key, label } = siteInfoFor(order)
    const summary: OrderSummary = {
      id: order.id,
      orderNumber: order.orderNumber,
      customerStatus: asOrderStatus(order.customerStatus),
      siteLabel: label,
      deliveryDate: order.deliveryDate,
      balance: money(order.balance),
      activeUnitCount: order.lineItems.filter(isActiveUnit).length,
    }
    const existing = groups.get(key)
    if (existing) {
      existing.orders.push(summary)
    } else {
      groups.set(key, { siteKey: key, siteLabel: label, orders: [summary] })
    }
  }

  return [...groups.values()].sort((a, b) => {
    const aActive = a.orders.some((o) => o.activeUnitCount > 0) ? 1 : 0
    const bActive = b.orders.some((o) => o.activeUnitCount > 0) ? 1 : 0
    if (aActive !== bActive) return bActive - aActive
    return a.siteLabel.localeCompare(b.siteLabel)
  })
}

export async function getOrderDetail(accountId: string, orderId: string): Promise<OrderDetail | null> {
  // findFirst with BOTH id and accountId — never findUnique by id alone,
  // so another account's order id resolves to null (page renders 404).
  const order = await prisma.order.findFirst({
    where: { id: orderId, accountId, isDeleted: false },
    include: { lineItems: { where: { isDeleted: false }, orderBy: { id: 'asc' } } },
  })
  if (!order) return null

  const placements = order.lineItems
    .filter((li) => !li.isChargeAdjustment)
    .sort((a, b) => {
      if (a.deliveryDate === null && b.deliveryDate === null) return 0
      if (a.deliveryDate === null) return 1
      if (b.deliveryDate === null) return -1
      return a.deliveryDate.getTime() - b.deliveryDate.getTime()
    })

  const adjustments = order.lineItems
    .filter((li) => li.isChargeAdjustment)
    .map((li) => ({
      recordType: li.recordType ?? 'Adjustment',
      lineTotal: money(li.lineTotal),
      description: li.notesToCustomer ?? li.product ?? li.recordType,
    }))

  const units: UnitDetail[] = placements.map((li) => ({
    id: li.id,
    jobType: li.jobType,
    product: li.product,
    size: li.size,
    quantity: li.quantity,
    status: asUnitStatus(li.customerStatus),
    deliveryDate: li.deliveryDate,
    endDate: li.endDate,
    lineTotal: money(li.lineTotal),
    adjustments: [],
  }))

  // SF has no parent-OLI link: attach adjustments only when exactly one placement exists.
  const soleUnit = units.length === 1 ? units[0] : undefined
  const unattachedAdjustments: OrderDetail['unattachedAdjustments'] = soleUnit ? [] : adjustments
  if (soleUnit) soleUnit.adjustments = adjustments

  const { label } = siteInfoFor(order)
  return {
    id: order.id,
    orderNumber: order.orderNumber,
    customerStatus: asOrderStatus(order.customerStatus),
    siteLabel: label,
    deliveryDate: order.deliveryDate,
    balance: money(order.balance),
    activeUnitCount: order.lineItems.filter(isActiveUnit).length,
    units,
    unattachedAdjustments,
    customerPo: order.customerPo,
    notesToCustomer: order.notesToCustomer,
    totals: {
      total: money(order.totalAfterTax),
      tax: money(order.taxAmount),
      charged: money(order.chargedAmount),
      balance: money(order.balance),
    },
  }
}
```

  Implementation notes the engineer must not change: `include: { lineItems: { orderBy: { id: 'asc' } } }` keeps adjustment ordering deterministic (placements are re-sorted by delivery date in code); the adjustment `recordType` falls back to `'Adjustment'` only to satisfy the non-null contract (sync always writes one of the three charge-adjustment record types); an order with zero placements sends all adjustments to `unattachedAdjustments`.

- [ ] Run the site-key unit test and orders integration test: `npx vitest run tests/unit/site-key.test.ts tests/integration/domain-orders.test.ts` — expected: PASS (17 tests: 7 unit + 10 integration). The dashboard suite still fails (module missing) — that is next.

- [ ] Create `lib/domain/dashboard.ts` — complete file:

```ts
import { prisma } from '../db'
import { getSites } from './orders'

export interface DashboardData {
  activeUnits: number
  sitesWithActiveUnits: number
  openBalance: string
  recentActivity: { at: Date; kind: 'delivery' | 'payment' | 'request'; text: string }[]
}

type ActivityItem = DashboardData['recentActivity'][number]

const ACTIVITY_LIMIT = 10

export async function getDashboard(accountId: string): Promise<DashboardData> {
  // Unit/site counts share getSites' grouping logic so the dashboard and the
  // Sites & Orders screen can never disagree.
  const sites = await getSites(accountId)
  let activeUnits = 0
  let sitesWithActiveUnits = 0
  for (const site of sites) {
    const siteActiveUnits = site.orders.reduce((sum, order) => sum + order.activeUnitCount, 0)
    activeUnits += siteActiveUnits
    if (siteActiveUnits > 0) sitesWithActiveUnits += 1
  }

  const balanceAgg = await prisma.order.aggregate({
    _sum: { balance: true },
    where: { accountId, isDeleted: false, balance: { gt: 0 } },
  })
  const openBalance = balanceAgg._sum.balance?.toFixed(2) ?? '0.00'

  // delivery_confirmations has no account column — join through this account's orders.
  const orders = await prisma.order.findMany({
    where: { accountId, isDeleted: false },
    select: { id: true, orderNumber: true },
  })
  const orderNumberById = new Map(orders.map((o) => [o.id, o.orderNumber]))
  const orderIds = [...orderNumberById.keys()]

  const activity: ActivityItem[] = []

  // Each source is pre-limited to the newest ACTIVITY_LIMIT rows; the merged
  // top-10 can only come from each source's top 10, so this is exact.
  const deliveries =
    orderIds.length === 0
      ? []
      : await prisma.deliveryConfirmation.findMany({
          where: { isDeleted: false, respondedOn: { not: null }, orderId: { in: orderIds } },
          orderBy: { respondedOn: 'desc' },
          take: ACTIVITY_LIMIT,
        })
  for (const confirmation of deliveries) {
    if (confirmation.respondedOn === null || confirmation.orderId === null) continue
    const orderNumber = orderNumberById.get(confirmation.orderId)
    if (orderNumber === undefined) continue
    activity.push({
      at: confirmation.respondedOn,
      kind: 'delivery',
      text: `Delivery confirmed — Order ${orderNumber}`,
    })
  }

  const payments = await prisma.transaction.findMany({
    where: { accountId, isDeleted: false, countsTowardTotals: true },
    orderBy: { sfCreatedAt: 'desc' },
    take: ACTIVITY_LIMIT,
  })
  for (const payment of payments) {
    const amount = payment.amount?.toFixed(2) ?? '0.00'
    const orderNumber = payment.orderId === null ? undefined : orderNumberById.get(payment.orderId)
    activity.push({
      at: payment.sfCreatedAt,
      kind: 'payment',
      text: orderNumber === undefined ? `Payment $${amount}` : `Payment $${amount} — Order ${orderNumber}`,
    })
  }

  const requests = await prisma.serviceRequest.findMany({
    where: { accountId },
    orderBy: { createdAt: 'desc' },
    take: ACTIVITY_LIMIT,
  })
  for (const request of requests) {
    activity.push({ at: request.createdAt, kind: 'request', text: `${request.type} request submitted` })
  }

  activity.sort((a, b) => b.at.getTime() - a.at.getTime())

  return {
    activeUnits,
    sitesWithActiveUnits,
    openBalance,
    recentActivity: activity.slice(0, ACTIVITY_LIMIT),
  }
}
```

- [ ] Run the dashboard integration test: `npx vitest run tests/integration/domain-dashboard.test.ts` — expected: PASS (4 tests).

- [ ] Run the whole suite with the DB up (`docker compose up -d` if not already): `npm test` — expected: PASS, no regressions in earlier tasks' tests.

- [ ] Commit: `git add -A && git commit -m "feat: orders and dashboard domain queries with site grouping and activity feed"`

---

### Task 13: Portal UI shell + orders screens

**Files:**
- Create: `app/(portal)/ui.tsx`
- Create: `app/(portal)/nav.tsx` (small client component — a server layout cannot read the current pathname, so active-state styling needs this one client boundary; it is an implementation detail of the layout entry in the file structure)
- Create: `app/(portal)/layout.tsx`
- Create: `app/(portal)/dashboard/page.tsx`
- Create: `app/(portal)/sites/page.tsx`
- Create: `app/(portal)/orders/[id]/page.tsx`
- Test: `tests/unit/ui-helpers.test.ts`

**Interfaces:**

Consumes (exact signatures from earlier tasks):
- `requireSession(): Promise<PortalSession & { accountId: string }>` and `getEligibleAccounts(email: string): Promise<{ id: string; name: string }[]>` from `lib/auth/session.ts` (`PortalSession` has `userId`, `email`, `accountId`, `accountName`). `requireSession` redirects to `/login` / `/select-account` itself — pages never handle that.
- `getLastSuccessfulSyncAt(): Promise<Date | null>` from `lib/sync/runSync.ts`.
- `getDashboard(accountId: string): Promise<DashboardData>` from `lib/domain/dashboard.ts` (Task 12).
- `getSites(accountId: string): Promise<SiteGroup[]>` and `getOrderDetail(accountId: string, orderId: string): Promise<OrderDetail | null>` plus types `SiteGroup`, `OrderSummary`, `UnitDetail`, `OrderDetail` from `lib/domain/orders.ts` (Task 12).
- `UnitStatus` from `lib/mapping/status.ts`.
- Tailwind + `app/layout.tsx`/`globals.css` root shell from Task 1; `POST /auth/logout` route from the auth task.

Produces (relied on by later tasks):
- The portal shell (`app/(portal)/layout.tsx`) that the billing and support screens render inside.
- Shared display helpers in `app/(portal)/ui.tsx` for reuse by the billing/support pages: `formatMoney(value: string | null): string | null`, `formatDate(value: Date | null): string | null`, `formatTime(value: Date): string`, `UNIT_STATUS_LABELS: Record<UnitStatus, string>`, `<Money value fallback? />`, `<DateText value fallback? />`, `<StatusPill status={'open' | 'closed'} />`.
- The `/dashboard`, `/sites`, and `/orders/[id]` pages exercised by the Task 17 e2e suite.

All pages are async server components; every domain call takes `(await requireSession()).accountId` as its first argument. Money strings arrive pre-fixed (`'1234.50'`) from the domain layer; the UI only adds `$`/thousands formatting.

**Steps:**

- [ ] Write the failing unit test for the pure display helpers at `tests/unit/ui-helpers.test.ts` (vitest transpiles the `.tsx` import; no DOM is needed because only pure functions and constants are tested — component *rendering* is covered end-to-end in Task 17, see the note in the final step):

```ts
import { describe, expect, it } from 'vitest'
import { UNIT_STATUS_LABELS, formatDate, formatMoney } from '../../app/(portal)/ui'

describe('formatMoney', () => {
  it("formats a fixed 2-dp string as US currency: '1234.50' → '$1,234.50'", () => {
    expect(formatMoney('1234.50')).toBe('$1,234.50')
  })

  it('formats zero and negative amounts', () => {
    expect(formatMoney('0.00')).toBe('$0.00')
    expect(formatMoney('-50.00')).toBe('-$50.00')
  })

  it('returns null for null or malformed input', () => {
    expect(formatMoney(null)).toBeNull()
    expect(formatMoney('not-money')).toBeNull()
  })
})

describe('formatDate', () => {
  it('formats DATE-column values in UTC so the calendar day never shifts', () => {
    // Prisma returns @db.Date columns as UTC-midnight Dates.
    expect(formatDate(new Date('2026-07-04T00:00:00Z'))).toBe('Jul 4, 2026')
  })

  it('returns null for null (DateText renders its fallback)', () => {
    expect(formatDate(null)).toBeNull()
  })
})

describe('UNIT_STATUS_LABELS', () => {
  it('has a customer-facing label for every unit status', () => {
    expect(UNIT_STATUS_LABELS).toEqual({
      scheduled: 'Scheduled',
      delivered: 'Delivered',
      removal_requested: 'Removal requested',
      completed: 'Completed',
      cancelled: 'Cancelled',
    })
  })
})
```

- [ ] Run it and confirm the failure: `npx vitest run tests/unit/ui-helpers.test.ts` — expected: module-resolution error (`Failed to resolve import "../../app/(portal)/ui"`), because the file does not exist yet.

- [ ] Create `app/(portal)/ui.tsx` — complete file:

```tsx
import type { UnitStatus } from '../../lib/mapping/status'

const currencyFormat = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
// @db.Date columns come back as UTC-midnight Dates; formatting in UTC keeps the calendar day stable.
const dateFormat = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', year: 'numeric', month: 'short', day: 'numeric' })
const timeFormat = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' })

export function formatMoney(value: string | null): string | null {
  if (value === null) return null
  const amount = Number(value)
  if (Number.isNaN(amount)) return null
  return currencyFormat.format(amount)
}

export function formatDate(value: Date | null): string | null {
  if (value === null) return null
  return dateFormat.format(value)
}

export function formatTime(value: Date): string {
  return timeFormat.format(value)
}

export const UNIT_STATUS_LABELS: Record<UnitStatus, string> = {
  scheduled: 'Scheduled',
  delivered: 'Delivered',
  removal_requested: 'Removal requested',
  completed: 'Completed',
  cancelled: 'Cancelled',
}

export function Money({ value, fallback = '—' }: { value: string | null; fallback?: string }) {
  return <span className="tabular-nums">{formatMoney(value) ?? fallback}</span>
}

export function DateText({ value, fallback = '—' }: { value: Date | null; fallback?: string }) {
  return <span>{formatDate(value) ?? fallback}</span>
}

export function StatusPill({ status }: { status: 'open' | 'closed' }) {
  if (status === 'open') {
    return (
      <span className="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">
        Open
      </span>
    )
  }
  return (
    <span className="inline-flex items-center rounded-full bg-slate-200 px-2.5 py-0.5 text-xs font-medium text-slate-600">
      Closed
    </span>
  )
}
```

- [ ] Run the helper tests again: `npx vitest run tests/unit/ui-helpers.test.ts` — expected: PASS (6 tests).

- [ ] Create `app/(portal)/nav.tsx` — complete file (client component; `usePathname` drives active-state styling; `/orders/*` highlights Sites & Orders since order detail is reached from there):

```tsx
'use client'

import Link from 'next/link'
import { usePathname } from 'next/navigation'

const NAV_ITEMS = [
  { href: '/dashboard', label: 'Dashboard' },
  { href: '/sites', label: 'Sites & Orders' },
  { href: '/billing', label: 'Billing' },
  { href: '/support', label: 'Support' },
] as const

export function PortalNav() {
  const pathname = usePathname() ?? ''
  return (
    <nav className="flex flex-col gap-1" aria-label="Portal">
      {NAV_ITEMS.map((item) => {
        const active =
          pathname === item.href ||
          pathname.startsWith(`${item.href}/`) ||
          (item.href === '/sites' && pathname.startsWith('/orders'))
        return (
          <Link
            key={item.href}
            href={item.href}
            aria-current={active ? 'page' : undefined}
            className={
              active
                ? 'rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white'
                : 'rounded-md px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 hover:text-slate-900'
            }
          >
            {item.label}
          </Link>
        )
      })}
    </nav>
  )
}
```

- [ ] Create `app/(portal)/layout.tsx` — complete file (server component: session guard, left nav, header with account name + conditional Switch link + logout form, staleness banner/footer):

```tsx
import type { ReactNode } from 'react'
import { getEligibleAccounts, requireSession } from '../../lib/auth/session'
import { getLastSuccessfulSyncAt } from '../../lib/sync/runSync'
import { PortalNav } from './nav'
import { formatTime } from './ui'

const STALE_AFTER_MS = 15 * 60 * 1000

export default async function PortalLayout({ children }: { children: ReactNode }) {
  const session = await requireSession()
  const [eligibleAccounts, lastSync] = await Promise.all([
    getEligibleAccounts(session.email),
    getLastSuccessfulSyncAt(),
  ])
  const isStale = lastSync === null || Date.now() - lastSync.getTime() > STALE_AFTER_MS

  return (
    <div className="min-h-screen bg-slate-50 text-slate-900">
      {isStale && (
        <div className="border-b border-amber-300 bg-amber-100 px-6 py-2 text-sm text-amber-900">
          {lastSync ? `Data as of ${formatTime(lastSync)} — sync delayed` : 'Data has not finished syncing — sync delayed'}
        </div>
      )}
      <div className="flex min-h-screen">
        <aside className="w-56 shrink-0 border-r border-slate-200 bg-white px-4 py-6">
          <div className="mb-8 px-3 text-lg font-semibold tracking-tight">Streamlink</div>
          <PortalNav />
        </aside>
        <div className="flex min-w-0 flex-1 flex-col">
          <header className="flex items-center justify-between border-b border-slate-200 bg-white px-6 py-3">
            <div className="flex items-center gap-3 text-sm">
              <span className="font-medium">{session.accountName ?? 'Account'}</span>
              {eligibleAccounts.length > 1 && (
                <a href="/select-account" className="text-slate-500 underline underline-offset-2 hover:text-slate-800">
                  Switch
                </a>
              )}
            </div>
            <form action="/auth/logout" method="post">
              <button type="submit" className="text-sm text-slate-500 hover:text-slate-800">
                Log out
              </button>
            </form>
          </header>
          <main className="flex-1 px-6 py-8">{children}</main>
          {!isStale && lastSync && (
            <footer className="px-6 pb-4 text-xs text-slate-400">Data as of {formatTime(lastSync)}</footer>
          )}
        </div>
      </div>
    </div>
  )
}
```

- [ ] Create `app/(portal)/dashboard/page.tsx` — complete file (three stat cards + recent-activity list):

```tsx
import { requireSession } from '../../../lib/auth/session'
import { getDashboard } from '../../../lib/domain/dashboard'
import { formatDate, formatMoney } from '../ui'

type ActivityKind = 'delivery' | 'payment' | 'request'

const KIND_LABELS: Record<ActivityKind, string> = {
  delivery: 'Delivery',
  payment: 'Payment',
  request: 'Request',
}

const KIND_STYLES: Record<ActivityKind, string> = {
  delivery: 'bg-emerald-100 text-emerald-800',
  payment: 'bg-sky-100 text-sky-800',
  request: 'bg-violet-100 text-violet-800',
}

export default async function DashboardPage() {
  const session = await requireSession()
  const data = await getDashboard(session.accountId)

  return (
    <div className="mx-auto max-w-5xl">
      <h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
      <div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
        <StatCard label="Active units" value={String(data.activeUnits)} />
        <StatCard label="Sites with active units" value={String(data.sitesWithActiveUnits)} />
        <StatCard label="Open balance" value={formatMoney(data.openBalance) ?? '$0.00'} />
      </div>

      <h2 className="mt-10 text-lg font-semibold">Recent activity</h2>
      {data.recentActivity.length === 0 ? (
        <p className="mt-3 text-sm text-slate-500">No recent activity.</p>
      ) : (
        <ul className="mt-3 divide-y divide-slate-100 rounded-lg border border-slate-200 bg-white">
          {data.recentActivity.map((item, index) => (
            <li key={index} className="flex items-center gap-3 px-4 py-3 text-sm">
              <span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${KIND_STYLES[item.kind]}`}>
                {KIND_LABELS[item.kind]}
              </span>
              <span className="min-w-0 flex-1 truncate text-slate-700">{item.text}</span>
              <span className="shrink-0 text-xs text-slate-400">{formatDate(item.at)}</span>
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

function StatCard({ label, value }: { label: string; value: string }) {
  return (
    <div className="rounded-lg border border-slate-200 bg-white px-5 py-4">
      <div className="text-sm text-slate-500">{label}</div>
      <div className="mt-1 text-3xl font-semibold tabular-nums tracking-tight">{value}</div>
    </div>
  )
}
```

- [ ] Create `app/(portal)/sites/page.tsx` — complete file (one card per `SiteGroup`; order rows link to `/orders/{id}`):

```tsx
import Link from 'next/link'
import { requireSession } from '../../../lib/auth/session'
import { getSites } from '../../../lib/domain/orders'
import { DateText, Money, StatusPill } from '../ui'

export default async function SitesPage() {
  const session = await requireSession()
  const sites = await getSites(session.accountId)

  return (
    <div className="mx-auto max-w-5xl">
      <h1 className="text-2xl font-semibold tracking-tight">Sites & Orders</h1>
      {sites.length === 0 ? (
        <p className="mt-6 text-sm text-slate-500">No orders yet.</p>
      ) : (
        <div className="mt-6 space-y-6">
          {sites.map((site) => (
            <section key={site.siteKey} className="rounded-lg border border-slate-200 bg-white">
              <h2 className="border-b border-slate-200 px-5 py-3 text-sm font-semibold text-slate-900">
                {site.siteLabel}
              </h2>
              <div className="overflow-x-auto">
                <table className="w-full text-sm">
                  <thead>
                    <tr className="text-left text-xs uppercase tracking-wide text-slate-400">
                      <th className="px-5 py-2 font-medium">Order</th>
                      <th className="px-5 py-2 font-medium">Status</th>
                      <th className="px-5 py-2 font-medium">Delivery</th>
                      <th className="px-5 py-2 font-medium">Active units</th>
                      <th className="px-5 py-2 text-right font-medium">Balance</th>
                    </tr>
                  </thead>
                  <tbody className="divide-y divide-slate-100">
                    {site.orders.map((order) => (
                      <tr key={order.id}>
                        <td className="px-5 py-3">
                          <Link
                            href={`/orders/${order.id}`}
                            className="font-medium text-slate-900 underline decoration-slate-300 underline-offset-2 hover:decoration-slate-900"
                          >
                            {order.orderNumber}
                          </Link>
                        </td>
                        <td className="px-5 py-3">
                          <StatusPill status={order.customerStatus} />
                        </td>
                        <td className="px-5 py-3 text-slate-600">
                          <DateText value={order.deliveryDate} />
                        </td>
                        <td className="px-5 py-3 tabular-nums text-slate-600">{order.activeUnitCount}</td>
                        <td className="px-5 py-3 text-right">
                          <Money value={order.balance} />
                        </td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            </section>
          ))}
        </div>
      )}
    </div>
  )
}
```

- [ ] Create `app/(portal)/orders/[id]/page.tsx` — complete file (`notFound()` on null; header with status pill, customer PO, notes callout; per-unit cards with status timeline and attached adjustments; unattached-adjustments section; totals footer). The contract's `OrderDetail` exposes `customerPo` but no `customerRef`, so the header shows `customerPo` only:

```tsx
import { notFound } from 'next/navigation'
import { requireSession } from '../../../../lib/auth/session'
import { getOrderDetail } from '../../../../lib/domain/orders'
import type { UnitDetail } from '../../../../lib/domain/orders'
import type { UnitStatus } from '../../../../lib/mapping/status'
import { DateText, Money, StatusPill, UNIT_STATUS_LABELS } from '../../ui'

const TIMELINE_STEPS: readonly UnitStatus[] = ['scheduled', 'delivered', 'removal_requested', 'completed']

type Adjustment = { recordType: string; lineTotal: string | null; description: string | null }

export default async function OrderDetailPage({ params }: { params: { id: string } }) {
  const session = await requireSession()
  const order = await getOrderDetail(session.accountId, params.id)
  if (!order) notFound()

  return (
    <div className="mx-auto max-w-4xl">
      <div className="flex flex-wrap items-center gap-3">
        <h1 className="text-2xl font-semibold tracking-tight">Order {order.orderNumber}</h1>
        <StatusPill status={order.customerStatus} />
      </div>
      <p className="mt-1 text-sm text-slate-500">{order.siteLabel}</p>
      {order.customerPo && <p className="mt-1 text-sm text-slate-500">Customer PO: {order.customerPo}</p>}
      {order.notesToCustomer && (
        <div className="mt-4 rounded-md border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900">
          {order.notesToCustomer}
        </div>
      )}

      <div className="mt-8 space-y-4">
        {order.units.length === 0 && <p className="text-sm text-slate-500">No units on this order.</p>}
        {order.units.map((unit) => (
          <UnitCard key={unit.id} unit={unit} />
        ))}
      </div>

      {order.unattachedAdjustments.length > 0 && (
        <section className="mt-8 rounded-lg border border-slate-200 bg-white px-5 py-4">
          <h2 className="text-sm font-semibold text-slate-900">Additional charges</h2>
          <p className="mt-0.5 text-xs text-slate-400">Charges on this order not tied to a single unit.</p>
          <AdjustmentList adjustments={order.unattachedAdjustments} />
        </section>
      )}

      <section className="mt-8 rounded-lg border border-slate-200 bg-white px-5 py-4">
        <dl className="space-y-2 text-sm">
          <TotalRow label="Total" value={order.totals.total} />
          <TotalRow label="Tax" value={order.totals.tax} />
          <TotalRow label="Charged" value={order.totals.charged} />
          <TotalRow label="Balance" value={order.totals.balance} emphasize />
        </dl>
      </section>
    </div>
  )
}

function UnitCard({ unit }: { unit: UnitDetail }) {
  const title = [unit.jobType, unit.product, unit.size]
    .filter((part): part is string => part !== null && part !== '')
    .join(' — ')
  return (
    <section className="rounded-lg border border-slate-200 bg-white px-5 py-4">
      <div className="flex flex-wrap items-baseline justify-between gap-2">
        <h2 className="text-sm font-semibold text-slate-900">{title || 'Unit'}</h2>
        {unit.quantity !== null && <span className="text-xs text-slate-400">Qty {unit.quantity}</span>}
      </div>
      <div className="mt-4">
        <StatusTimeline status={unit.status} />
      </div>
      <dl className="mt-4 grid grid-cols-2 gap-x-6 gap-y-1 text-sm sm:grid-cols-3">
        <div>
          <dt className="text-xs text-slate-400">Delivery</dt>
          <dd className="text-slate-700">
            <DateText value={unit.deliveryDate} />
          </dd>
        </div>
        <div>
          <dt className="text-xs text-slate-400">End date</dt>
          <dd className="text-slate-700">
            <DateText value={unit.endDate} />
          </dd>
        </div>
        <div>
          <dt className="text-xs text-slate-400">Line total</dt>
          <dd className="text-slate-700">
            <Money value={unit.lineTotal} />
          </dd>
        </div>
      </dl>
      {unit.adjustments.length > 0 && (
        <div className="mt-4 border-t border-slate-100 pt-3">
          <h3 className="text-xs font-semibold uppercase tracking-wide text-slate-400">Additional charges</h3>
          <AdjustmentList adjustments={unit.adjustments} />
        </div>
      )}
    </section>
  )
}

function StatusTimeline({ status }: { status: UnitStatus }) {
  if (status === 'cancelled') {
    return (
      <div className="inline-flex items-center gap-2 rounded-md bg-red-50 px-3 py-1.5 text-sm font-medium text-red-700">
        <span className="h-2.5 w-2.5 rounded-full bg-red-600" />
        Cancelled
      </div>
    )
  }
  const currentIndex = TIMELINE_STEPS.indexOf(status)
  return (
    <ol className="flex flex-wrap items-center gap-2">
      {TIMELINE_STEPS.map((step, index) => {
        const done = index < currentIndex
        const current = index === currentIndex
        return (
          <li key={step} className="flex items-center gap-2">
            {index > 0 && <span className={`h-px w-6 ${index <= currentIndex ? 'bg-slate-900' : 'bg-slate-300'}`} />}
            <span
              className={`h-2.5 w-2.5 rounded-full ${
                done ? 'bg-slate-900' : current ? 'bg-emerald-500 ring-4 ring-emerald-100' : 'bg-slate-300'
              }`}
            />
            <span className={`text-xs ${current ? 'font-semibold text-slate-900' : done ? 'text-slate-700' : 'text-slate-400'}`}>
              {UNIT_STATUS_LABELS[step]}
            </span>
          </li>
        )
      })}
    </ol>
  )
}

function AdjustmentList({ adjustments }: { adjustments: Adjustment[] }) {
  return (
    <ul className="mt-2 space-y-1">
      {adjustments.map((adjustment, index) => (
        <li key={index} className="flex items-baseline justify-between gap-4 text-sm">
          <span className="text-slate-700">
            {adjustment.recordType}
            {adjustment.description && adjustment.description !== adjustment.recordType ? (
              <span className="text-slate-400"> — {adjustment.description}</span>
            ) : null}
          </span>
          <Money value={adjustment.lineTotal} />
        </li>
      ))}
    </ul>
  )
}

function TotalRow({ label, value, emphasize = false }: { label: string; value: string | null; emphasize?: boolean }) {
  return (
    <div className="flex items-baseline justify-between">
      <dt className={emphasize ? 'font-semibold text-slate-900' : 'text-slate-500'}>{label}</dt>
      <dd className={emphasize ? 'font-semibold text-slate-900' : 'text-slate-700'}>
        <Money value={value} />
      </dd>
    </div>
  )
}
```

- [ ] Typecheck the new components against the strict config: `npx tsc --noEmit` — expected: no errors.

- [ ] Run the unit suite: `npx vitest run tests/unit` — expected: PASS (including `ui-helpers` and all earlier tasks' unit tests). Then run the full suite with the local DB up: `npm test` — expected: PASS. **Note:** page *rendering* is deliberately not unit-tested here — the login → dashboard → sites → order-detail flow is covered by the Playwright e2e suite in Task 17; this task only unit-tests the pure helpers in `ui.tsx`.

- [ ] Optional manual smoke check (requires Task 17's seed script; skip until then — `scripts/seed-dev.ts` does not exist yet, and this check also needs earlier tasks' auth in place): `docker compose up -d`, `npx tsx scripts/seed-dev.ts`, `npm run dev`, request a magic link with `EMAIL_MODE=log` (link prints to the console), then visit `/dashboard`, `/sites`, and an order detail page. Verify the staleness banner appears when no sync has run.

- [ ] Commit: `git add -A && git commit -m "feat: portal shell with dashboard, sites, and order detail screens"`
### Task 14: Billing domain + billing page

**Files:**
- Create: `lib/domain/billing.ts`
- Create: `app/(portal)/billing/page.tsx`
- Modify: `lib/domain/dashboard.ts` (created in the dashboard domain task)
- Test: `tests/integration/billing.test.ts`

**Interfaces:**

*Consumes* (from earlier tasks):
- `prisma: PrismaClient` from `lib/db.ts` (Task 2), with models `Account`, `Order`, `Invoice`, `Transaction`, `SavedCard` per the authoritative schema.
- `requireSession(): Promise<PortalSession & { accountId: string }>` from `lib/auth/session.ts` (auth task).
- `getDashboard(accountId: string): Promise<DashboardData>` from `lib/domain/dashboard.ts` (dashboard domain task) — this task refactors its open-balance computation.
- `Money`, `formatMoney(value: string | null): string | null`, and `formatDate(value: Date | null): string | null` from `app/(portal)/ui.tsx` (Task 13) — reused exactly for all money/date rendering, never re-implemented.
- Local Postgres via docker-compose (Task 2) on port 5433, db `streamlink`; `DATABASE_URL` in `.env` points at it.

*Produces* (later tasks and pages rely on):
- `getBilling(accountId: string): Promise<BillingData>` and `BillingData` exactly per the Interface Contracts.
- `sumOpenBalance(accountId: string): Promise<string>` — **the** shared open-balance helper (sum of `orders.balance` where `balance > 0`, `.toFixed(2)` string). **Contract resolution:** the spec wants dashboard `openBalance` and billing `totalOpenBalance` to be the same figure from one helper. The helper lives in `lib/domain/billing.ts`; `lib/domain/dashboard.ts` imports it. Nothing else may re-implement this sum.
- `/billing` page rendering summary card, open invoices, payment history, cards on file.

**Business rules implemented here (from the spec + design brief):**
- `openInvoices`: invoices where `accountId`, `isDeleted=false`, `status` equals `'Open'` **case-insensitively** (SF `Status__c` is a free string), `amountDue > 0`; ordered `dueDate` asc with **nulls last**; `amountDue` emitted as `.toFixed(2)` string.
- `payments`: transactions where `accountId`, `countsTowardTotals=true` (this flag is set false at sync time for Decline/Void/`Voided__c` rows), `isDeleted=false`; ordered `sfCreatedAt` desc, take 50; `amount` as `.toFixed(2)`; `orderNumber` resolved by looking up `orders` by the transaction's `orderId` (the schema has **no relation** between Transaction and Order — do a batched `findMany` + `Map`); `orderId` is nullable → `orderNumber: null`.
- `cards`: saved_cards where `accountId`, `isDeleted=false`, ordered `isPrimary` desc (primary first).
- `totalOpenBalance`: `sumOpenBalance(accountId)` — NOT a sum of invoice `amountDue`.
- Note: the contract's `payments` entry has no `itemDescription` field, so the page's "Description" column renders `type` plus `Order {orderNumber}` when present. Do not add fields to `BillingData`.

**Steps:**

- [ ] Ensure local Postgres is up and the schema applied (integration tests require it): `docker compose up -d && npx prisma db push`.

- [ ] Write the failing integration test. Create `tests/integration/billing.test.ts` with exactly:

```ts
import { beforeEach, describe, expect, it } from 'vitest'
import { prisma } from '../../lib/db'
import { getBilling, sumOpenBalance } from '../../lib/domain/billing'
import { getDashboard } from '../../lib/domain/dashboard'

// All ids are prefixed 'T14' so cleanup never touches other test files' rows.
const ACCT_A = 'T14AcctA0000000001'
const ACCT_B = 'T14AcctB0000000001'
const MOD = new Date('2026-07-01T00:00:00Z')

async function cleanup(): Promise<void> {
  await prisma.transaction.deleteMany({ where: { id: { startsWith: 'T14' } } })
  await prisma.invoice.deleteMany({ where: { id: { startsWith: 'T14' } } })
  await prisma.savedCard.deleteMany({ where: { id: { startsWith: 'T14' } } })
  await prisma.order.deleteMany({ where: { id: { startsWith: 'T14' } } })
  await prisma.account.deleteMany({ where: { id: { startsWith: 'T14' } } })
}

beforeEach(async () => {
  await cleanup()
  await prisma.account.createMany({
    data: [
      { id: ACCT_A, name: 'Acme Restoration', recordType: 'Business_Account', sfModstamp: MOD },
      { id: ACCT_B, name: 'Beta Builders', recordType: 'Business_Account', sfModstamp: MOD },
    ],
  })
  await prisma.order.createMany({
    data: [
      { id: 'T14OrderA100000001', accountId: ACCT_A, orderNumber: 'CO-1001', customerStatus: 'open', balance: '100.50', sfModstamp: MOD },
      { id: 'T14OrderA200000001', accountId: ACCT_A, orderNumber: 'CO-1002', customerStatus: 'open', balance: '49.50', sfModstamp: MOD },
      { id: 'T14OrderA300000001', accountId: ACCT_A, orderNumber: 'CO-1003', customerStatus: 'closed', balance: '-25.00', sfModstamp: MOD },
      { id: 'T14OrderA400000001', accountId: ACCT_A, orderNumber: 'CO-1004', customerStatus: 'closed', balance: null, sfModstamp: MOD },
      { id: 'T14OrderB100000001', accountId: ACCT_B, orderNumber: 'CO-2001', customerStatus: 'open', balance: '999.99', sfModstamp: MOD },
    ],
  })
  await prisma.invoice.createMany({
    data: [
      { id: 'T14Inv100000000001', name: 'INV-1001', accountId: ACCT_A, status: 'Open', amountDue: '150.00', dueDate: new Date('2026-08-01T00:00:00Z'), sfModstamp: MOD },
      { id: 'T14Inv200000000001', name: 'INV-1002', accountId: ACCT_A, status: 'open', amountDue: '10.00', dueDate: null, sfModstamp: MOD },
      { id: 'T14Inv300000000001', name: 'INV-1003', accountId: ACCT_A, status: 'Open', amountDue: '25.00', dueDate: new Date('2026-07-20T00:00:00Z'), sfModstamp: MOD },
      { id: 'T14Inv400000000001', name: 'INV-1004', accountId: ACCT_A, status: 'Paid In Full', amountDue: '0.00', dueDate: new Date('2026-06-01T00:00:00Z'), sfModstamp: MOD },
      { id: 'T14Inv500000000001', name: 'INV-1005', accountId: ACCT_A, status: 'Open', amountDue: '0.00', dueDate: new Date('2026-09-01T00:00:00Z'), sfModstamp: MOD },
      { id: 'T14Inv600000000001', name: 'INV-2001', accountId: ACCT_B, status: 'Open', amountDue: '500.00', dueDate: new Date('2026-08-15T00:00:00Z'), sfModstamp: MOD },
      { id: 'T14Inv700000000001', name: 'INV-1007', accountId: ACCT_A, status: 'Open', amountDue: '5.00', dueDate: null, isDeleted: true, sfModstamp: MOD },
    ],
  })
  await prisma.transaction.createMany({
    data: [
      { id: 'T14Txn100000000001', name: 'T-100001', accountId: ACCT_A, type: 'Payments', countsTowardTotals: true, amount: '75.25', cardLast4: '4242', orderId: 'T14OrderA100000001', sfCreatedAt: new Date('2026-07-10T14:00:00Z'), sfModstamp: MOD },
      { id: 'T14Txn200000000001', name: 'T-100002', accountId: ACCT_A, type: 'Credit', countsTowardTotals: true, amount: '20.00', cardLast4: null, orderId: null, sfCreatedAt: new Date('2026-07-12T09:00:00Z'), sfModstamp: MOD },
      { id: 'T14Txn300000000001', name: 'T-100003', accountId: ACCT_A, type: 'Void', countsTowardTotals: false, isVoided: true, amount: '33.00', orderId: null, sfCreatedAt: new Date('2026-07-13T09:00:00Z'), sfModstamp: MOD },
      { id: 'T14Txn400000000001', name: 'T-100004', accountId: ACCT_A, type: 'Payments', countsTowardTotals: true, amount: '11.00', orderId: null, isDeleted: true, sfCreatedAt: new Date('2026-07-14T09:00:00Z'), sfModstamp: MOD },
      { id: 'T14Txn500000000001', name: 'T-200001', accountId: ACCT_B, type: 'Payments', countsTowardTotals: true, amount: '60.00', orderId: 'T14OrderB100000001', sfCreatedAt: new Date('2026-07-11T09:00:00Z'), sfModstamp: MOD },
    ],
  })
  await prisma.savedCard.createMany({
    data: [
      { id: 'T14Card10000000001', accountId: ACCT_A, label: 'Visa ending 4242', last4: '4242', cardType: 'Visa', expiryDisplay: '04/2028', isPrimary: false, sfModstamp: MOD },
      { id: 'T14Card20000000001', accountId: ACCT_A, label: 'Amex ending 0005', last4: '0005', cardType: 'American Express', expiryDisplay: '11/2027', isPrimary: true, sfModstamp: MOD },
      { id: 'T14Card30000000001', accountId: ACCT_A, label: 'Old card', last4: '9999', cardType: 'Visa', expiryDisplay: '01/2020', isPrimary: false, isDeleted: true, sfModstamp: MOD },
      { id: 'T14Card40000000001', accountId: ACCT_B, label: 'MC ending 5454', last4: '5454', cardType: 'Mastercard', expiryDisplay: '09/2026', isPrimary: true, sfModstamp: MOD },
    ],
  })
})

describe('getBilling', () => {
  it('returns only the requested account data (cross-account isolation)', async () => {
    const a = await getBilling(ACCT_A)
    expect(a.openInvoices.map((i) => i.id)).not.toContain('T14Inv600000000001')
    expect(a.payments.map((p) => p.id)).not.toContain('T14Txn500000000001')
    expect(a.cards.map((c) => c.label)).not.toContain('MC ending 5454')

    const b = await getBilling(ACCT_B)
    expect(b.openInvoices.map((i) => i.id)).toEqual(['T14Inv600000000001'])
    expect(b.payments.map((p) => p.id)).toEqual(['T14Txn500000000001'])
    expect(b.cards.map((c) => c.label)).toEqual(['MC ending 5454'])
    expect(b.totalOpenBalance).toBe('999.99')
  })

  it('excludes voided/declined (countsTowardTotals=false) and deleted transactions', async () => {
    const a = await getBilling(ACCT_A)
    expect(a.payments.map((p) => p.id)).toEqual(['T14Txn200000000001', 'T14Txn100000000001'])
  })

  it('excludes closed and zero-due invoices and matches status case-insensitively', async () => {
    const a = await getBilling(ACCT_A)
    expect(a.openInvoices.map((i) => i.id)).toEqual([
      'T14Inv300000000001', // 2026-07-20
      'T14Inv100000000001', // 2026-08-01
      'T14Inv200000000001', // null due date sorts last; lowercase 'open' still included
    ])
  })

  it('emits money as fixed two-decimal strings', async () => {
    const a = await getBilling(ACCT_A)
    expect(a.openInvoices.map((i) => i.amountDue)).toEqual(['25.00', '150.00', '10.00'])
    for (const p of a.payments) expect(p.amount).toMatch(/^-?\d+\.\d{2}$/)
    expect(a.payments[0]?.amount).toBe('20.00')
    expect(a.payments[1]?.amount).toBe('75.25')
    expect(a.totalOpenBalance).toBe('150.00')
  })

  it('resolves orderNumber through the orders table and tolerates null orderId', async () => {
    const a = await getBilling(ACCT_A)
    expect(a.payments[1]?.orderNumber).toBe('CO-1001')
    expect(a.payments[0]?.orderNumber).toBeNull()
  })

  it('lists cards primary-first and excludes deleted cards', async () => {
    const a = await getBilling(ACCT_A)
    expect(a.cards.map((c) => c.label)).toEqual(['Amex ending 0005', 'Visa ending 4242'])
    expect(a.cards[0]).toEqual({
      label: 'Amex ending 0005',
      last4: '0005',
      cardType: 'American Express',
      expiryDisplay: '11/2027',
      isPrimary: true,
    })
  })

  it('caps payment history at 50 rows, newest first', async () => {
    await prisma.transaction.createMany({
      data: Array.from({ length: 60 }, (_, i) => ({
        id: `T14Bulk${String(i).padStart(11, '0')}`,
        name: `T-B${i}`,
        accountId: ACCT_A,
        type: 'Payments',
        countsTowardTotals: true,
        amount: '1.00',
        sfCreatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, i)),
        sfModstamp: MOD,
      })),
    })
    const a = await getBilling(ACCT_A)
    expect(a.payments).toHaveLength(50)
    expect(a.payments[0]?.id).toBe('T14Txn200000000001')
  })
})

describe('sumOpenBalance', () => {
  it('sums only positive, non-deleted order balances', async () => {
    expect(await sumOpenBalance(ACCT_A)).toBe('150.00') // 100.50 + 49.50; -25.00 and null excluded
    expect(await sumOpenBalance(ACCT_B)).toBe('999.99')
  })

  it('is the same figure the dashboard reports (single shared helper)', async () => {
    const [dash, billing] = await Promise.all([getDashboard(ACCT_A), getBilling(ACCT_A)])
    expect(dash.openBalance).toBe(billing.totalOpenBalance)
    expect(dash.openBalance).toBe('150.00')
  })
})
```

- [ ] Run it and confirm the failure is a missing module, not a broken environment: `npx vitest run tests/integration/billing.test.ts` — expected failure: `Failed to resolve import "../../lib/domain/billing"` (the file does not exist yet).

- [ ] Create `lib/domain/billing.ts` with exactly:

```ts
import { Prisma } from '@prisma/client'
import { prisma } from '../db'

export interface BillingData {
  openInvoices: { id: string; name: string; amountDue: string; dueDate: Date | null; status: string | null }[]
  payments: { id: string; name: string; type: string; amount: string; cardLast4: string | null; at: Date; orderNumber: string | null }[]
  cards: { label: string; last4: string | null; cardType: string | null; expiryDisplay: string | null; isPrimary: boolean }[]
  totalOpenBalance: string
}

const ZERO = new Prisma.Decimal(0)

/**
 * The single source of truth for an account's open balance:
 * sum of orders.balance (NewBalance__c) over non-deleted orders with balance > 0.
 * Consumed by getBilling (totalOpenBalance) AND lib/domain/dashboard.ts (openBalance).
 */
export async function sumOpenBalance(accountId: string): Promise<string> {
  const agg = await prisma.order.aggregate({
    where: { accountId, isDeleted: false, balance: { gt: 0 } },
    _sum: { balance: true },
  })
  return (agg._sum.balance ?? ZERO).toFixed(2)
}

/** Transaction/Invoice rows carry orderId without a Prisma relation — batch-resolve order numbers. */
async function orderNumbersById(accountId: string, orderIds: string[]): Promise<Map<string, string>> {
  if (orderIds.length === 0) return new Map()
  const orders = await prisma.order.findMany({
    where: { id: { in: orderIds }, accountId }, // accountId re-check: never leak another account's order number
    select: { id: true, orderNumber: true },
  })
  return new Map(orders.map((o) => [o.id, o.orderNumber]))
}

export async function getBilling(accountId: string): Promise<BillingData> {
  const [invoiceRows, transactionRows, cardRows, totalOpenBalance] = await Promise.all([
    prisma.invoice.findMany({
      where: {
        accountId,
        isDeleted: false,
        status: { equals: 'Open', mode: 'insensitive' }, // Status__c is a free string in SF
        amountDue: { gt: 0 },
      },
      orderBy: [{ dueDate: { sort: 'asc', nulls: 'last' } }, { name: 'asc' }],
    }),
    prisma.transaction.findMany({
      where: { accountId, countsTowardTotals: true, isDeleted: false },
      orderBy: { sfCreatedAt: 'desc' },
      take: 50,
    }),
    prisma.savedCard.findMany({
      where: { accountId, isDeleted: false },
      orderBy: [{ isPrimary: 'desc' }, { label: 'asc' }],
    }),
    sumOpenBalance(accountId),
  ])

  const orderIds = [...new Set(transactionRows.map((t) => t.orderId).filter((id): id is string => id !== null))]
  const numbers = await orderNumbersById(accountId, orderIds)

  return {
    openInvoices: invoiceRows.map((i) => ({
      id: i.id,
      name: i.name,
      amountDue: (i.amountDue ?? ZERO).toFixed(2),
      dueDate: i.dueDate,
      status: i.status,
    })),
    payments: transactionRows.map((t) => ({
      id: t.id,
      name: t.name,
      type: t.type ?? '',
      amount: (t.amount ?? ZERO).toFixed(2),
      cardLast4: t.cardLast4,
      at: t.sfCreatedAt,
      orderNumber: t.orderId ? (numbers.get(t.orderId) ?? null) : null,
    })),
    cards: cardRows.map((c) => ({
      label: c.label,
      last4: c.last4,
      cardType: c.cardType,
      expiryDisplay: c.expiryDisplay,
      isPrimary: c.isPrimary,
    })),
    totalOpenBalance,
  }
}
```

- [ ] Modify `lib/domain/dashboard.ts` so the dashboard's `openBalance` comes from the shared helper (this is the contract resolution: one helper, owned by billing, imported by dashboard). Add to the imports at the top:

```ts
import { sumOpenBalance } from './billing'
```

  Then, inside `getDashboard`, DELETE the inline open-balance computation the dashboard task wrote (a `prisma.order.aggregate` with `_sum: { balance: true }` over `{ accountId, isDeleted: false, balance: { gt: 0 } }`, followed by a `.toFixed(2)`) and REPLACE it with:

```ts
const openBalance = await sumOpenBalance(accountId)
```

  Keep every other computation in `getDashboard` unchanged and keep returning `openBalance` in the `DashboardData` object. If the dashboard task used a different local variable name, keep that name — the requirement is that the value is `await sumOpenBalance(accountId)` and the inline aggregate is gone. Remove any import (e.g. `Prisma` from `@prisma/client`) that becomes unused, so `tsc`/lint stay clean.

- [ ] Run `npx vitest run tests/integration/billing.test.ts` — expected: **all tests PASS**. Then run the whole suite `npm test` — expected: PASS (in particular the dashboard task's tests must still pass after the refactor).

- [ ] Create `app/(portal)/billing/page.tsx` with exactly (all money/date rendering goes through Task 13's `app/(portal)/ui.tsx` helpers — no local formatters):

```tsx
import { requireSession } from '../../../lib/auth/session'
import { getBilling } from '../../../lib/domain/billing'
import { Money, formatDate, formatMoney } from '../ui'

export default async function BillingPage(): Promise<JSX.Element> {
  const session = await requireSession()
  const billing = await getBilling(session.accountId)

  return (
    <div className="space-y-8">
      <h1 className="text-2xl font-semibold text-gray-900">Billing</h1>

      <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
        <p className="text-sm font-medium text-gray-500">Open balance</p>
        <p className="mt-1 text-3xl font-semibold text-gray-900">{formatMoney(billing.totalOpenBalance) ?? '$0.00'}</p>
        <p className="mt-1 text-xs text-gray-400">Sum of open balances across your orders</p>
      </div>

      <section>
        <h2 className="text-lg font-semibold text-gray-900">Open invoices</h2>
        {billing.openInvoices.length === 0 ? (
          <p className="mt-2 text-sm text-gray-500">No open invoices.</p>
        ) : (
          <div className="mt-3 overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
            <table className="min-w-full divide-y divide-gray-200 text-sm">
              <thead className="bg-gray-50 text-left text-xs font-medium uppercase tracking-wide text-gray-500">
                <tr>
                  <th className="px-4 py-3">Invoice</th>
                  <th className="px-4 py-3">Due date</th>
                  <th className="px-4 py-3">Status</th>
                  <th className="px-4 py-3 text-right">Amount due</th>
                </tr>
              </thead>
              <tbody className="divide-y divide-gray-100">
                {billing.openInvoices.map((inv) => (
                  <tr key={inv.id}>
                    <td className="px-4 py-3 font-medium text-gray-900">{inv.name}</td>
                    <td className="px-4 py-3 text-gray-600">{formatDate(inv.dueDate) ?? '—'}</td>
                    <td className="px-4 py-3">
                      <span className="inline-flex rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800">
                        {inv.status ?? 'Open'}
                      </span>
                    </td>
                    <td className="px-4 py-3 text-right font-medium text-gray-900">
                      <Money value={inv.amountDue} />
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </section>

      <section>
        <h2 className="text-lg font-semibold text-gray-900">Payment history</h2>
        {billing.payments.length === 0 ? (
          <p className="mt-2 text-sm text-gray-500">No payments yet.</p>
        ) : (
          <div className="mt-3 overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
            <table className="min-w-full divide-y divide-gray-200 text-sm">
              <thead className="bg-gray-50 text-left text-xs font-medium uppercase tracking-wide text-gray-500">
                <tr>
                  <th className="px-4 py-3">Date</th>
                  <th className="px-4 py-3">Payment</th>
                  <th className="px-4 py-3">Description</th>
                  <th className="px-4 py-3">Card</th>
                  <th className="px-4 py-3 text-right">Amount</th>
                </tr>
              </thead>
              <tbody className="divide-y divide-gray-100">
                {billing.payments.map((p) => (
                  <tr key={p.id}>
                    <td className="px-4 py-3 text-gray-600">{formatDate(p.at)}</td>
                    <td className="px-4 py-3 font-medium text-gray-900">{p.name}</td>
                    <td className="px-4 py-3 text-gray-600">
                      {[p.type, p.orderNumber ? `Order ${p.orderNumber}` : null].filter(Boolean).join(' — ') || '—'}
                    </td>
                    <td className="px-4 py-3 text-gray-600">{p.cardLast4 ? `•••• ${p.cardLast4}` : '—'}</td>
                    <td className="px-4 py-3 text-right font-medium text-gray-900">
                      <Money value={p.amount} />
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </section>

      <section>
        <h2 className="text-lg font-semibold text-gray-900">Cards on file</h2>
        {billing.cards.length === 0 ? (
          <p className="mt-2 text-sm text-gray-500">No saved cards.</p>
        ) : (
          <ul className="mt-3 divide-y divide-gray-100 rounded-lg border border-gray-200 bg-white shadow-sm">
            {billing.cards.map((c, idx) => (
              <li key={`${c.label}-${idx}`} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-3 text-sm">
                <span className="font-medium text-gray-900">
                  {c.cardType ?? 'Card'} •••• {c.last4 ?? '····'}
                </span>
                <span className="text-gray-600">{c.label}</span>
                {c.expiryDisplay && <span className="text-gray-400">Expires {c.expiryDisplay}</span>}
                {c.isPrimary && (
                  <span className="inline-flex rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800">
                    Primary
                  </span>
                )}
              </li>
            ))}
          </ul>
        )}
      </section>
    </div>
  )
}
```

- [ ] Typecheck the app including the new page: `npx tsc --noEmit` — expected: no errors.

- [ ] Optional manual check (requires Task 17's seed script; skip until then): `npx tsx scripts/seed-dev.ts && npm run dev`, log in with the seeded dev user, and visit `/billing` — summary card, three sections, no vendor/cost data anywhere.

- [ ] Run the full suite once more (`npm test`, expected PASS) and commit:

```
git add lib/domain/billing.ts lib/domain/dashboard.ts app/(portal)/billing/page.tsx tests/integration/billing.test.ts
git commit -m "feat: billing domain with shared open-balance helper and billing page"
```

### Task 15: Support domain + case list/detail pages

**Files:**
- Create: `lib/domain/support.ts`
- Create: `app/(portal)/support/page.tsx` (created here; Task 16 modifies it — the structure below leaves an explicit insertion point so that modification is a clean addition)
- Create: `app/(portal)/support/[id]/page.tsx`
- Test: `tests/integration/support.test.ts`

**Interfaces:**

*Consumes* (from earlier tasks — exact signatures):
- `prisma: PrismaClient` from `lib/db.ts` (Task 3), with models `Account`, `Order`, `CaseRecord` per the authoritative schema (Task 2). `CaseRecord.status` already holds `'open' | 'closed'` — written by `mapCase` at sync time; this task passes it through, it does NOT re-map raw SF statuses.
- `requireSession(): Promise<PortalSession & { accountId: string }>` from `lib/auth/session.ts` (Task 11).
- `StatusPill({ status }: { status: 'open' | 'closed' })` and `DateText({ value, fallback? }: { value: Date | null; fallback?: string })` from `app/(portal)/ui.tsx` (Task 13) — reused exactly, never re-created.
- Local Postgres from Task 2 (`npm run db:up`, port 5433, db `streamlink`) — integration test only.

*Produces* (relied on by Task 16 and the Task 17 e2e suite) — exactly the plan contract:

```ts
// lib/domain/support.ts
export interface CaseSummary { id: string; caseNumber: string; subject: string | null; type: string | null; status: 'open' | 'closed'; createdAt: Date; orderNumber: string | null }
export async function getCases(accountId: string): Promise<CaseSummary[]>
export async function getCaseDetail(accountId: string, caseId: string): Promise<(CaseSummary & { description: string | null; resolution: string | null; updates: string | null; closedAt: Date | null }) | null>
```

**Business rules implemented here:**
- `getCases`: `caseRecord.findMany` where `{ accountId, isDeleted: false }`, `orderBy: { sfCreatedAt: 'desc' }`, `take: 100`. `orderNumber` is resolved by a second query over `orders` for the distinct non-null `orderId`s (the schema has no Case→Order relation) — batched `findMany` + `Map`, re-scoped by `accountId` so a foreign order's number can never leak. Null or dangling `orderId` → `orderNumber: null`.
- `getCaseDetail`: `findFirst({ where: { id: caseId, accountId, isDeleted: false } })` — never `findUnique` by id alone — returns `null` when absent/foreign/deleted (the page 404s). Same orderNumber resolution.
- Postgres only; no Salesforce imports in this module.
- The `/support` page's "New request" button links to `/support/new`, which does not exist until Task 16 — the link 404s until then; that is expected and harmless.

**Steps:**

- [ ] Ensure the local database is up with the schema applied (integration test precondition): `npm run db:up && npx prisma db push` — expected: exits 0.

- [ ] Write the failing integration test. Create `tests/integration/support.test.ts` with exactly:

```ts
import { afterAll, beforeAll, describe, expect, it } from 'vitest'

// Must be set before lib/db is loaded, hence the dynamic imports below.
process.env.DATABASE_URL ??= 'postgresql://streamlink:streamlink@localhost:5433/streamlink'

const { prisma } = await import('../../lib/db')
const { getCaseDetail, getCases } = await import('../../lib/domain/support')

// All ids are prefixed 't15-' so cleanup never touches other test files' rows.
const A = 't15-acct-a'
const B = 't15-acct-b'
const MOD = new Date('2026-07-16T00:00:00Z')

async function cleanup(): Promise<void> {
  await prisma.caseRecord.deleteMany({ where: { id: { startsWith: 't15-' } } })
  await prisma.order.deleteMany({ where: { accountId: { in: [A, B] } } })
  await prisma.account.deleteMany({ where: { id: { in: [A, B] } } })
}

beforeAll(async () => {
  await cleanup()
  await prisma.account.createMany({
    data: [
      { id: A, name: 'Acme Co', recordType: 'Business_Account', sfModstamp: MOD },
      { id: B, name: 'Rival Co', recordType: 'Business_Account', sfModstamp: MOD },
    ],
  })
  await prisma.order.createMany({
    data: [
      { id: 't15-order-a1', accountId: A, orderNumber: 'CO-5001', customerStatus: 'open', sfModstamp: MOD },
      { id: 't15-order-b1', accountId: B, orderNumber: 'CO-6001', customerStatus: 'open', sfModstamp: MOD },
    ],
  })
  await prisma.caseRecord.createMany({
    data: [
      {
        id: 't15-case-open',
        accountId: A,
        caseNumber: '00001001',
        subject: 'Dumpster overflowing',
        type: 'Service Issue',
        status: 'open',
        description: 'The 20-yard at the north gate is full.',
        updates: 'Vendor notified, swap scheduled.',
        orderId: 't15-order-a1',
        sfCreatedAt: new Date('2026-07-10T15:00:00Z'),
        sfModstamp: MOD,
      },
      {
        id: 't15-case-closed',
        accountId: A,
        caseNumber: '00001002',
        subject: 'Billing question',
        type: 'Invoice Issue',
        status: 'closed',
        description: 'Why was I charged twice?',
        resolution: 'Duplicate charge credited.',
        orderId: null,
        closedAt: new Date('2026-06-20T12:00:00Z'),
        sfCreatedAt: new Date('2026-06-15T09:00:00Z'),
        sfModstamp: MOD,
      },
      {
        id: 't15-case-dangling',
        accountId: A,
        caseNumber: '00001003',
        subject: null,
        type: null,
        status: 'open',
        orderId: 't15-order-gone', // order never synced — must resolve to null, not crash
        sfCreatedAt: new Date('2026-07-12T08:00:00Z'),
        sfModstamp: MOD,
      },
      {
        id: 't15-case-deleted',
        accountId: A,
        caseNumber: '00001004',
        subject: 'Deleted in SF',
        status: 'open',
        isDeleted: true,
        sfCreatedAt: new Date('2026-07-14T08:00:00Z'),
        sfModstamp: MOD,
      },
      {
        id: 't15-case-b',
        accountId: B,
        caseNumber: '00002001',
        subject: 'Rival case',
        type: 'Delivery ETA',
        status: 'open',
        orderId: 't15-order-b1',
        sfCreatedAt: new Date('2026-07-13T08:00:00Z'),
        sfModstamp: MOD,
      },
    ],
  })
})

afterAll(async () => {
  await cleanup()
  await prisma.$disconnect()
})

describe('getCases', () => {
  it('returns the account cases newest-first, excluding deleted rows', async () => {
    // The take-100 cap is intentionally not boundary-tested (plan decision).
    const cases = await getCases(A)
    expect(cases.map((c) => c.id)).toEqual(['t15-case-dangling', 't15-case-open', 't15-case-closed'])
    expect(cases.map((c) => c.caseNumber)).toEqual(['00001003', '00001001', '00001002'])
  })

  it("never returns another account's cases (cross-account isolation)", async () => {
    const aCases = await getCases(A)
    expect(aCases.map((c) => c.id)).not.toContain('t15-case-b')
    const bCases = await getCases(B)
    expect(bCases.map((c) => c.id)).toEqual(['t15-case-b'])
    expect(bCases[0]?.orderNumber).toBe('CO-6001')
  })

  it('passes the stored status through as open/closed and resolves order numbers', async () => {
    const byId = new Map((await getCases(A)).map((c) => [c.id, c]))
    expect(byId.get('t15-case-open')?.status).toBe('open')
    expect(byId.get('t15-case-closed')?.status).toBe('closed')
    expect(byId.get('t15-case-open')?.orderNumber).toBe('CO-5001')
  })

  it('tolerates null and dangling order references', async () => {
    const byId = new Map((await getCases(A)).map((c) => [c.id, c]))
    expect(byId.get('t15-case-closed')?.orderNumber).toBeNull() // orderId null
    expect(byId.get('t15-case-dangling')?.orderNumber).toBeNull() // orderId points nowhere
  })
})

describe('getCaseDetail', () => {
  it('returns the full detail for an owned case', async () => {
    const detail = await getCaseDetail(A, 't15-case-open')
    expect(detail).toEqual({
      id: 't15-case-open',
      caseNumber: '00001001',
      subject: 'Dumpster overflowing',
      type: 'Service Issue',
      status: 'open',
      createdAt: new Date('2026-07-10T15:00:00Z'),
      orderNumber: 'CO-5001',
      description: 'The 20-yard at the north gate is full.',
      resolution: null,
      updates: 'Vendor notified, swap scheduled.',
      closedAt: null,
    })
  })

  it('includes resolution and closedAt on closed cases', async () => {
    const detail = await getCaseDetail(A, 't15-case-closed')
    expect(detail?.resolution).toBe('Duplicate charge credited.')
    expect(detail?.closedAt).toEqual(new Date('2026-06-20T12:00:00Z'))
    expect(detail?.orderNumber).toBeNull()
  })

  it("returns null for another account's case (cross-account negative test)", async () => {
    expect(await getCaseDetail(A, 't15-case-b')).toBeNull()
    expect(await getCaseDetail(B, 't15-case-open')).toBeNull()
  })

  it('returns null for deleted and unknown cases', async () => {
    expect(await getCaseDetail(A, 't15-case-deleted')).toBeNull()
    expect(await getCaseDetail(A, 'no-such-case')).toBeNull()
  })
})
```

- [ ] Run it and confirm the failure is a missing module, not a broken environment: `npx vitest run tests/integration/support.test.ts` — expected failure: `Failed to resolve import "../../lib/domain/support"` (the file does not exist yet).

- [ ] Create `lib/domain/support.ts` with exactly:

```ts
import { prisma } from '../db'

export interface CaseSummary {
  id: string
  caseNumber: string
  subject: string | null
  type: string | null
  status: 'open' | 'closed'
  createdAt: Date
  orderNumber: string | null
}

/** cases.status is written by the sync mapper as 'open' | 'closed'; narrow defensively. */
function asCaseStatus(raw: string): 'open' | 'closed' {
  return raw === 'open' ? 'open' : 'closed'
}

/**
 * Case rows carry orderId without a Prisma relation — batch-resolve order
 * numbers, re-scoped by accountId so a foreign order's number can never leak.
 */
async function orderNumbersById(accountId: string, orderIds: string[]): Promise<Map<string, string>> {
  if (orderIds.length === 0) return new Map()
  const orders = await prisma.order.findMany({
    where: { id: { in: orderIds }, accountId },
    select: { id: true, orderNumber: true },
  })
  return new Map(orders.map((o) => [o.id, o.orderNumber]))
}

export async function getCases(accountId: string): Promise<CaseSummary[]> {
  const rows = await prisma.caseRecord.findMany({
    where: { accountId, isDeleted: false },
    orderBy: { sfCreatedAt: 'desc' },
    take: 100,
  })
  const orderIds = [...new Set(rows.map((r) => r.orderId).filter((id): id is string => id !== null))]
  const numbers = await orderNumbersById(accountId, orderIds)
  return rows.map((r) => ({
    id: r.id,
    caseNumber: r.caseNumber,
    subject: r.subject,
    type: r.type,
    status: asCaseStatus(r.status),
    createdAt: r.sfCreatedAt,
    orderNumber: r.orderId ? (numbers.get(r.orderId) ?? null) : null,
  }))
}

export async function getCaseDetail(
  accountId: string,
  caseId: string,
): Promise<
  | (CaseSummary & { description: string | null; resolution: string | null; updates: string | null; closedAt: Date | null })
  | null
> {
  // findFirst with BOTH id and accountId — never findUnique by id alone,
  // so another account's case id resolves to null (page renders 404).
  const row = await prisma.caseRecord.findFirst({ where: { id: caseId, accountId, isDeleted: false } })
  if (!row) return null
  const numbers = await orderNumbersById(accountId, row.orderId ? [row.orderId] : [])
  return {
    id: row.id,
    caseNumber: row.caseNumber,
    subject: row.subject,
    type: row.type,
    status: asCaseStatus(row.status),
    createdAt: row.sfCreatedAt,
    orderNumber: row.orderId ? (numbers.get(row.orderId) ?? null) : null,
    description: row.description,
    resolution: row.resolution,
    updates: row.updates,
    closedAt: row.closedAt,
  }
}
```

- [ ] Run `npx vitest run tests/integration/support.test.ts` — expected: **PASS (8 tests)**.

- [ ] Create `app/(portal)/support/page.tsx` with exactly (Task 16 replaces the marked insertion point with the toast + "Your requests" section — keep the marker comment):

```tsx
import Link from 'next/link'
import { requireSession } from '../../../lib/auth/session'
import { getCases } from '../../../lib/domain/support'
import { DateText, StatusPill } from '../ui'

export default async function SupportPage() {
  const session = await requireSession()
  const cases = await getCases(session.accountId)

  return (
    <div className="mx-auto max-w-5xl">
      <div className="flex flex-wrap items-center justify-between gap-3">
        <h1 className="text-2xl font-semibold tracking-tight">Support</h1>
        <Link
          href="/support/new"
          className="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700"
        >
          New request
        </Link>
      </div>

      {/* Task 16 inserts the submitted toast and the "Your requests" section here, above the tickets. */}

      <section className="mt-8">
        <h2 className="text-lg font-semibold">Support tickets</h2>
        {cases.length === 0 ? (
          <p className="mt-3 text-sm text-slate-500">No support tickets yet.</p>
        ) : (
          <div className="mt-3 overflow-x-auto rounded-lg border border-slate-200 bg-white">
            <table className="w-full text-sm">
              <thead>
                <tr className="text-left text-xs uppercase tracking-wide text-slate-400">
                  <th className="px-5 py-2 font-medium">Case</th>
                  <th className="px-5 py-2 font-medium">Subject</th>
                  <th className="px-5 py-2 font-medium">Type</th>
                  <th className="px-5 py-2 font-medium">Status</th>
                  <th className="px-5 py-2 font-medium">Created</th>
                  <th className="px-5 py-2 font-medium">Order</th>
                </tr>
              </thead>
              <tbody className="divide-y divide-slate-100">
                {cases.map((c) => (
                  <tr key={c.id}>
                    <td className="px-5 py-3">
                      <Link
                        href={`/support/${c.id}`}
                        className="font-medium text-slate-900 underline decoration-slate-300 underline-offset-2 hover:decoration-slate-900"
                      >
                        {c.caseNumber}
                      </Link>
                    </td>
                    <td className="px-5 py-3 text-slate-700">{c.subject ?? '—'}</td>
                    <td className="px-5 py-3 text-slate-600">{c.type ?? '—'}</td>
                    <td className="px-5 py-3">
                      <StatusPill status={c.status} />
                    </td>
                    <td className="px-5 py-3 text-slate-600">
                      <DateText value={c.createdAt} />
                    </td>
                    <td className="px-5 py-3 text-slate-600">{c.orderNumber ?? '—'}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </section>
    </div>
  )
}
```

- [ ] Create `app/(portal)/support/[id]/page.tsx` with exactly:

```tsx
import { notFound } from 'next/navigation'
import { requireSession } from '../../../../lib/auth/session'
import { getCaseDetail } from '../../../../lib/domain/support'
import { DateText, StatusPill } from '../../ui'

export default async function CaseDetailPage({ params }: { params: { id: string } }) {
  const session = await requireSession()
  const caseDetail = await getCaseDetail(session.accountId, params.id)
  if (!caseDetail) notFound()

  return (
    <div className="mx-auto max-w-3xl">
      <p className="text-sm text-slate-500">Case {caseDetail.caseNumber}</p>
      <div className="mt-1 flex flex-wrap items-center gap-3">
        <h1 className="text-2xl font-semibold tracking-tight">{caseDetail.subject ?? 'Support case'}</h1>
        <StatusPill status={caseDetail.status} />
      </div>

      <dl className="mt-6 grid grid-cols-2 gap-x-6 gap-y-3 rounded-lg border border-slate-200 bg-white px-5 py-4 text-sm sm:grid-cols-4">
        <div>
          <dt className="text-xs text-slate-400">Type</dt>
          <dd className="text-slate-700">{caseDetail.type ?? '—'}</dd>
        </div>
        <div>
          <dt className="text-xs text-slate-400">Created</dt>
          <dd className="text-slate-700">
            <DateText value={caseDetail.createdAt} />
          </dd>
        </div>
        <div>
          <dt className="text-xs text-slate-400">Closed</dt>
          <dd className="text-slate-700">
            <DateText value={caseDetail.closedAt} />
          </dd>
        </div>
        <div>
          <dt className="text-xs text-slate-400">Order</dt>
          <dd className="text-slate-700">{caseDetail.orderNumber ?? '—'}</dd>
        </div>
      </dl>

      <section className="mt-6 rounded-lg border border-slate-200 bg-white px-5 py-4">
        <h2 className="text-sm font-semibold text-slate-900">Description</h2>
        <p className="mt-2 whitespace-pre-wrap text-sm text-slate-700">{caseDetail.description ?? '—'}</p>
      </section>

      {caseDetail.resolution && (
        <section className="mt-6 rounded-lg border border-emerald-200 bg-emerald-50 px-5 py-4">
          <h2 className="text-sm font-semibold text-emerald-900">Resolution</h2>
          <p className="mt-2 whitespace-pre-wrap text-sm text-emerald-900">{caseDetail.resolution}</p>
        </section>
      )}

      {caseDetail.updates && (
        <section className="mt-6 rounded-lg border border-slate-200 bg-white px-5 py-4">
          <h2 className="text-sm font-semibold text-slate-900">Updates</h2>
          <p className="mt-2 whitespace-pre-wrap text-sm text-slate-700">{caseDetail.updates}</p>
        </section>
      )}
    </div>
  )
}
```

- [ ] Typecheck: `npx tsc --noEmit` — expected: no errors.

- [ ] Run the full suite (DB up): `npm test` — expected: PASS, no regressions.

- [ ] Commit:

```
git add lib/domain/support.ts "app/(portal)/support/page.tsx" "app/(portal)/support/[id]/page.tsx" tests/integration/support.test.ts
git commit -m "feat: support domain with case list and detail pages"
```

---

### Task 16: Service requests end-to-end (create → queue → push to SF Case)

**Files:**
- Create: `lib/serviceRequests/create.ts`
- Create: `lib/serviceRequests/push.ts`
- Create: `jobs/drain.ts`
- Create: `app/api/service-requests/route.ts`
- Create: `app/(portal)/support/new/page.tsx`
- Create: `app/(portal)/support/new/NewRequestForm.tsx`
- Modify: `app/(portal)/support/page.tsx` (from Task 15 — adds the submitted toast and the "Your requests" section)
- Modify: `lib/salesforce/mock.ts` (from Task 5, LIMIT-extended by Task 8 — memoize the mock client instance so the push-failure test can `vi.spyOn` the exact object `push.ts` uses; behavior is unchanged because all mock state already lives at module level)
- Modify: `lib/auth/email.ts` (from Task 10 — adds `sendServiceRequestConfirmationEmail`, the spec §6 submission-confirmation email)
- Test: `tests/integration/serviceRequests.test.ts`
- Test: `tests/integration/serviceRequestsRoute.test.ts`

**Interfaces:**

*Consumes* (from earlier tasks — exact signatures):
- `prisma: PrismaClient` from `lib/db.ts` (Task 3), with models `ServiceRequest`, `AuditLog`, `Order`, `OrderLineItem`, `PortalUser`, `Account` (Task 2).
- `logger: pino.Logger` from `lib/logger.ts` (Task 3).
- `getSfClient(): Promise<SfClient>` and `interface SfClient { queryAll(soqlText: string): Promise<SfRecord[]>; create(objectName: string, fields: Record<string, unknown>): Promise<string> }` from `lib/salesforce/client.ts` (Task 5); `mockSf = { reset(), addRecords(obj, rows), created }` from `lib/salesforce/mock.ts` (Task 5).
- `getSession(): Promise<PortalSession | null>` and `requireSession(): Promise<PortalSession & { accountId: string }>` from `lib/auth/session.ts` (Task 11); `PortalSession` carries `userId`.
- `getSites(accountId: string): Promise<SiteGroup[]>` from `lib/domain/orders.ts` (Task 12).
- `DateText` from `app/(portal)/ui.tsx` (Task 13) — reused exactly.
- The `jobs/sync.ts` module shape from Task 8 (authored in tasks-C2): exports a Lambda `handler` (deployed as `index.handler`, per Task 18) and doubles as a CLI entry via Task 8's guard `if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href)`. `jobs/drain.ts` mirrors it with that exact guard so all job entries are shaped identically.
- `sendMagicLinkEmail`'s `EMAIL_MODE` (`log` default | `ses`) plumbing in `lib/auth/email.ts` (Task 10) — this task appends the service-request confirmation sender to that module, reusing its existing `logger`, `getSesClient()` and `SendEmailCommand` internals.
- Local Postgres from Task 2 (`npm run db:up`) — integration test only. `SF_MODE=mock` (the default) — the test never touches the network.

*Produces* (relied on by Task 17 e2e and Task 18 deployment) — exactly the plan contract:

```ts
// lib/serviceRequests/create.ts
export const REQUEST_TYPES = ['Swap Dumpster','Removal Request','Delivery ETA','Service Issue','Invoice Issue','Cancellation','Potty Service','Customer Inquiry'] as const
export type RequestType = typeof REQUEST_TYPES[number]
export interface CreateRequestInput { type: RequestType; description: string; orderId?: string; orderLineItemId?: string }
export async function createServiceRequest(accountId: string, userId: string, input: CreateRequestInput): Promise<{ id: string }>
export interface ServiceRequestSummary { id: string; type: string; description: string; pushStage: string; sfCaseId: string | null; createdAt: Date; orderNumber: string | null }
export async function listServiceRequests(accountId: string): Promise<ServiceRequestSummary[]>
// lib/serviceRequests/push.ts
export const CUSTOMER_SUPPORT_RECORD_TYPE_ID = '0123o000001cpHZAAY'
export async function pushPendingServiceRequests(): Promise<{ pushed: number; failed: number }>
// jobs/drain.ts
export async function handler(): Promise<{ pushed: number; failed: number }>
// lib/auth/email.ts (extending Task 10's module)
export async function sendServiceRequestConfirmationEmail(to: string, requestType: string, orderNumber: string | null): Promise<void>
```

Plus `POST /api/service-requests` (201 `{id}` | 400 `{error:'invalid_body'}` — covers zod failures AND the domain's `invalid_type`/`invalid_description` | 401 `{error:'unauthorized'}` | 404 `{error:'order_not_found'|'line_item_not_found'}`), a fire-and-forget confirmation email on successful submission (spec §6), the `/support/new` page, and the upgraded `/support` page.

**Business rules implemented here (ambiguity resolutions called out):**
- `createServiceRequest` validation order: type ∈ `REQUEST_TYPES` (else `Error('invalid_type')`); description trimmed to 1..4000 chars (else `Error('invalid_description')`); when `orderId` given, `prisma.order.findFirst({ where: { id: orderId, accountId, isDeleted: false } })` must hit (else `Error('order_not_found')`); when `orderLineItemId` given, the OLI must exist with `orderId` equal to the given `orderId` (else `Error('line_item_not_found')`) — **an `orderLineItemId` without an `orderId` is rejected as `line_item_not_found`**, because OLI ownership can only be verified through its parent order (never pass `orderId: undefined` into a Prisma where-clause — Prisma drops undefined filters, which would skip the ownership check).
- Insert `ServiceRequest` with `pushStage: 'pending'` and write the `AuditLog` row `{ action: 'service_request_created', userId, accountId, detail: { type, orderId } }` in one `$transaction`.
- The API route's zod schema uses `z.string().trim().min(1).max(4000)` for `description`, so a whitespace-only description fails up front as 400 `invalid_body`; as defense in depth the route's catch also maps the domain's `invalid_type`/`invalid_description` errors to 400 `invalid_body` — they must never escape as a 500.
- Confirmation email (spec §6): after `createServiceRequest` succeeds, the route resolves the order number (looked up when `orderId` was given — the order is already ownership-validated; `null` otherwise) and calls `void sendServiceRequestConfirmationEmail(session.email, type, orderNumber).catch(err => logger.error({ err }, 'confirmation email failed'))` — fire-and-forget, so an email hiccup never fails an already-persisted request. Nothing is sent when validation fails.
- `pushPendingServiceRequests` backoff: candidates are `pushStage='pending' AND attempts < 5`, oldest-first, take 20, then filtered in JS — due when `attempts === 0` or `updatedAt <= now − 2^attempts minutes`. Failure sets `attempts+1`, `lastError = String(err).slice(0, 500)`, and `pushStage = attempts+1 >= 5 ? 'failed' : 'pending'`. The returned `failed` counts **only newly-terminal** failures.
- `/support` "Your requests" chips: `pending` → amber "Submitted"; `pushed` → green "Received"; `failed` → red "Needs attention — call us". **No case-detail link on pushed rows** — when `sfCaseId` is set AND the Case has already synced into the local `cases` mirror, plain text `Case {caseNumber}` is shown next to the chip (resolved via the `getCases` result already on the page); if the sync hasn't caught up yet, the chip stands alone. The section renders only when the account has at least one request.
- `REQUEST_TYPES` lives in `lib/serviceRequests/create.ts`, which imports Prisma — the client component must never import it. The server page imports it and passes it down as a plain prop.

**Steps:**

- [ ] Ensure the local database is up with the schema applied: `npm run db:up && npx prisma db push` — expected: exits 0.

- [ ] Write the failing integration test. Create `tests/integration/serviceRequests.test.ts` with exactly:

```ts
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RequestType } from '../../lib/serviceRequests/create'

// Must be set before lib/db is loaded, hence the dynamic imports below.
process.env.SF_MODE = 'mock'
process.env.DATABASE_URL ??= 'postgresql://streamlink:streamlink@localhost:5433/streamlink'

const { prisma } = await import('../../lib/db')
const { getSfClient } = await import('../../lib/salesforce/client')
const { mockSf } = await import('../../lib/salesforce/mock')
const { createServiceRequest, listServiceRequests, REQUEST_TYPES } = await import('../../lib/serviceRequests/create')
const { CUSTOMER_SUPPORT_RECORD_TYPE_ID, pushPendingServiceRequests } = await import('../../lib/serviceRequests/push')

// All ids are prefixed 't16-' so cleanup never touches other test files' rows.
const A = 't16-acct-a'
const B = 't16-acct-b'
const USER_A = 't16-user-a'
const USER_B = 't16-user-b'
const MOD = new Date('2026-07-16T00:00:00Z')

async function cleanup(): Promise<void> {
  await prisma.auditLog.deleteMany({ where: { accountId: { in: [A, B] } } })
  await prisma.serviceRequest.deleteMany({ where: { accountId: { in: [A, B] } } })
  await prisma.portalUser.deleteMany({ where: { id: { in: [USER_A, USER_B] } } })
  await prisma.orderLineItem.deleteMany({ where: { order: { accountId: { in: [A, B] } } } })
  await prisma.order.deleteMany({ where: { accountId: { in: [A, B] } } })
  await prisma.account.deleteMany({ where: { id: { in: [A, B] } } })
}

beforeEach(async () => {
  await cleanup()
  mockSf.reset()
  await prisma.account.createMany({
    data: [
      { id: A, name: 'Acme Co', recordType: 'Business_Account', sfModstamp: MOD },
      { id: B, name: 'Rival Co', recordType: 'Business_Account', sfModstamp: MOD },
    ],
  })
  await prisma.order.createMany({
    data: [
      { id: 't16-order-a1', accountId: A, orderNumber: 'CO-7001', customerStatus: 'open', sfModstamp: MOD },
      { id: 't16-order-a2', accountId: A, orderNumber: 'CO-7002', customerStatus: 'open', sfModstamp: MOD },
      { id: 't16-order-b1', accountId: B, orderNumber: 'CO-8001', customerStatus: 'open', sfModstamp: MOD },
      { id: 't16-order-del', accountId: A, orderNumber: 'CO-7099', customerStatus: 'open', isDeleted: true, sfModstamp: MOD },
    ],
  })
  await prisma.orderLineItem.createMany({
    data: [
      { id: 't16-oli-a1', orderId: 't16-order-a1', isChargeAdjustment: false, customerStatus: 'delivered', sfModstamp: MOD },
      { id: 't16-oli-a2', orderId: 't16-order-a2', isChargeAdjustment: false, customerStatus: 'delivered', sfModstamp: MOD },
    ],
  })
  await prisma.portalUser.createMany({
    data: [
      { id: USER_A, email: 't16-user-a@example.com' },
      { id: USER_B, email: 't16-user-b@example.com' },
    ],
  })
})

afterEach(() => {
  vi.restoreAllMocks()
})

afterAll(async () => {
  await cleanup()
  await prisma.$disconnect()
})

it('REQUEST_TYPES matches the contract exactly', () => {
  expect(REQUEST_TYPES).toEqual([
    'Swap Dumpster',
    'Removal Request',
    'Delivery ETA',
    'Service Issue',
    'Invoice Issue',
    'Cancellation',
    'Potty Service',
    'Customer Inquiry',
  ])
})

describe('createServiceRequest', () => {
  it('creates a pending request and writes an audit-log row', async () => {
    const { id } = await createServiceRequest(A, USER_A, {
      type: 'Swap Dumpster',
      description: '  Please swap the full 20-yard.  ',
      orderId: 't16-order-a1',
      orderLineItemId: 't16-oli-a1',
    })

    const row = await prisma.serviceRequest.findUniqueOrThrow({ where: { id } })
    expect(row.accountId).toBe(A)
    expect(row.userId).toBe(USER_A)
    expect(row.type).toBe('Swap Dumpster')
    expect(row.description).toBe('Please swap the full 20-yard.') // trimmed
    expect(row.orderId).toBe('t16-order-a1')
    expect(row.orderLineItemId).toBe('t16-oli-a1')
    expect(row.pushStage).toBe('pending')
    expect(row.attempts).toBe(0)
    expect(row.sfCaseId).toBeNull()

    const audit = await prisma.auditLog.findFirst({ where: { action: 'service_request_created', accountId: A } })
    expect(audit?.userId).toBe(USER_A)
    expect(audit?.detail).toEqual({ type: 'Swap Dumpster', orderId: 't16-order-a1' })
  })

  it('rejects an unknown request type', async () => {
    await expect(
      createServiceRequest(A, USER_A, { type: 'Pizza Delivery' as RequestType, description: 'hello' }),
    ).rejects.toThrow('invalid_type')
  })

  it('rejects empty and oversized descriptions, accepts exactly 4000 chars', async () => {
    await expect(createServiceRequest(A, USER_A, { type: 'Service Issue', description: '   ' })).rejects.toThrow(
      'invalid_description',
    )
    await expect(
      createServiceRequest(A, USER_A, { type: 'Service Issue', description: 'x'.repeat(4001) }),
    ).rejects.toThrow('invalid_description')
    const ok = await createServiceRequest(A, USER_A, { type: 'Service Issue', description: 'x'.repeat(4000) })
    expect(ok.id).toBeTruthy()
  })

  it("rejects another account's, deleted, and unknown orders (cross-account negative test)", async () => {
    await expect(
      createServiceRequest(A, USER_A, { type: 'Removal Request', description: 'take it away', orderId: 't16-order-b1' }),
    ).rejects.toThrow('order_not_found')
    await expect(
      createServiceRequest(A, USER_A, { type: 'Removal Request', description: 'take it away', orderId: 't16-order-del' }),
    ).rejects.toThrow('order_not_found')
    await expect(
      createServiceRequest(A, USER_A, { type: 'Removal Request', description: 'take it away', orderId: 'no-such-order' }),
    ).rejects.toThrow('order_not_found')
    expect(await prisma.serviceRequest.count({ where: { accountId: A } })).toBe(0)
  })

  it('rejects a line item that does not belong to the given order', async () => {
    // t16-oli-a2 belongs to order a2, not a1
    await expect(
      createServiceRequest(A, USER_A, {
        type: 'Swap Dumpster',
        description: 'swap',
        orderId: 't16-order-a1',
        orderLineItemId: 't16-oli-a2',
      }),
    ).rejects.toThrow('line_item_not_found')
    // an OLI reference without an order cannot be verified → rejected
    await expect(
      createServiceRequest(A, USER_A, { type: 'Swap Dumpster', description: 'swap', orderLineItemId: 't16-oli-a1' }),
    ).rejects.toThrow('line_item_not_found')
  })
})

describe('pushPendingServiceRequests', () => {
  it('pushes a pending request to Salesforce as a Case and marks it pushed', async () => {
    const { id } = await createServiceRequest(A, USER_A, {
      type: 'Swap Dumpster',
      description: 'Please swap the full 20-yard.',
      orderId: 't16-order-a1',
      orderLineItemId: 't16-oli-a1',
    })

    expect(await pushPendingServiceRequests()).toEqual({ pushed: 1, failed: 0 })

    expect(mockSf.created).toHaveLength(1)
    expect(mockSf.created[0]).toEqual({
      obj: 'Case',
      fields: {
        RecordTypeId: CUSTOMER_SUPPORT_RECORD_TYPE_ID,
        Origin: 'Customer Portal',
        Type: 'Swap Dumpster',
        Subject: '[Portal] Swap Dumpster — CO-7001',
        Description: 'Please swap the full 20-yard.\n\n—\nSubmitted via Streamlink by t16-user-a@example.com',
        AccountId: A,
        Customer_Order__c: 't16-order-a1',
        Order_Line_Item__c: 't16-oli-a1',
      },
    })

    const row = await prisma.serviceRequest.findUniqueOrThrow({ where: { id } })
    expect(row.pushStage).toBe('pushed')
    expect(row.sfCaseId).toBe('MOCKID000000000001')

    // a second run has nothing to do — the push is idempotent per request
    expect(await pushPendingServiceRequests()).toEqual({ pushed: 0, failed: 0 })
    expect(mockSf.created).toHaveLength(1)
  })

  it('omits order fields and uses the General subject for requests without an order', async () => {
    await createServiceRequest(A, USER_A, { type: 'Customer Inquiry', description: 'Question about my account.' })

    expect(await pushPendingServiceRequests()).toEqual({ pushed: 1, failed: 0 })

    const fields = mockSf.created[0]?.fields ?? {}
    expect(fields.Subject).toBe('[Portal] Customer Inquiry — General')
    expect(fields.Origin).toBe('Customer Portal')
    expect(fields).not.toHaveProperty('Customer_Order__c')
    expect(fields).not.toHaveProperty('Order_Line_Item__c')
  })

  it('records failures with backoff: an immediately-repeated run does not retry', async () => {
    const { id } = await createServiceRequest(A, USER_A, { type: 'Service Issue', description: 'broken door' })

    // EXACT TECHNIQUE: getSfClient() in mock mode returns ONE memoized
    // instance (see the lib/salesforce/mock.ts modification in this task), so
    // spying here intercepts the very object push.ts receives.
    const client = await getSfClient()
    const createSpy = vi.spyOn(client, 'create').mockRejectedValue(new Error('SF unavailable'))

    // failed counts only newly-TERMINAL failures — a first failure is not terminal
    expect(await pushPendingServiceRequests()).toEqual({ pushed: 0, failed: 0 })
    expect(createSpy).toHaveBeenCalledTimes(1)

    let row = await prisma.serviceRequest.findUniqueOrThrow({ where: { id } })
    expect(row.attempts).toBe(1)
    expect(row.pushStage).toBe('pending')
    expect(row.lastError).toBe('Error: SF unavailable')

    // attempts=1 with updatedAt=now sits inside the 2^1-minute backoff window
    expect(await pushPendingServiceRequests()).toEqual({ pushed: 0, failed: 0 })
    expect(createSpy).toHaveBeenCalledTimes(1) // no second SF call
    row = await prisma.serviceRequest.findUniqueOrThrow({ where: { id } })
    expect(row.attempts).toBe(1)
  })

  it("marks a request 'failed' after the 5th consecutive failure and never retries it", async () => {
    const { id } = await createServiceRequest(A, USER_A, { type: 'Cancellation', description: 'cancel it' })
    const client = await getSfClient()
    const createSpy = vi.spyOn(client, 'create').mockRejectedValue(new Error('still down'))

    for (let run = 1; run <= 5; run += 1) {
      if (run > 1) {
        // Explicitly setting updatedAt overrides Prisma's @updatedAt — backdate
        // the row past every backoff window (max 2^4 = 16 min) so it is due.
        await prisma.serviceRequest.update({
          where: { id },
          data: { updatedAt: new Date(Date.now() - 60 * 60_000) },
        })
      }
      expect(await pushPendingServiceRequests()).toEqual({ pushed: 0, failed: run === 5 ? 1 : 0 })
    }

    const row = await prisma.serviceRequest.findUniqueOrThrow({ where: { id } })
    expect(row.attempts).toBe(5)
    expect(row.pushStage).toBe('failed')
    expect(createSpy).toHaveBeenCalledTimes(5)

    // terminal rows are excluded by both the pushStage and attempts filters
    await prisma.serviceRequest.update({ where: { id }, data: { updatedAt: new Date(Date.now() - 60 * 60_000) } })
    expect(await pushPendingServiceRequests()).toEqual({ pushed: 0, failed: 0 })
    expect(createSpy).toHaveBeenCalledTimes(5)
  })
})

describe('listServiceRequests', () => {
  it("returns only the account's requests, newest first, with order numbers resolved", async () => {
    const first = await createServiceRequest(A, USER_A, { type: 'Swap Dumpster', description: 'first', orderId: 't16-order-a1' })
    const second = await createServiceRequest(A, USER_A, { type: 'Delivery ETA', description: 'second' })
    await createServiceRequest(B, USER_B, { type: 'Service Issue', description: 'not yours', orderId: 't16-order-b1' })

    // pin distinct createdAt values so the desc ordering is deterministic
    await prisma.serviceRequest.update({ where: { id: first.id }, data: { createdAt: new Date('2026-07-15T10:00:00Z') } })
    await prisma.serviceRequest.update({ where: { id: second.id }, data: { createdAt: new Date('2026-07-16T10:00:00Z') } })

    const list = await listServiceRequests(A)
    expect(list.map((r) => r.id)).toEqual([second.id, first.id])
    expect(list[0]).toMatchObject({ type: 'Delivery ETA', description: 'second', pushStage: 'pending', sfCaseId: null, orderNumber: null })
    expect(list[1]?.orderNumber).toBe('CO-7001')
    expect(list.map((r) => r.description)).not.toContain('not yours')

    const bList = await listServiceRequests(B)
    expect(bList).toHaveLength(1)
    expect(bList[0]?.orderNumber).toBe('CO-8001')
  })
})
```

- [ ] Run it and confirm the failure: `npx vitest run tests/integration/serviceRequests.test.ts` — expected failure: `Failed to resolve import "../../lib/serviceRequests/create"` (the module does not exist yet).

- [ ] Create `lib/serviceRequests/create.ts` with exactly:

```ts
import { prisma } from '../db'

export const REQUEST_TYPES = [
  'Swap Dumpster',
  'Removal Request',
  'Delivery ETA',
  'Service Issue',
  'Invoice Issue',
  'Cancellation',
  'Potty Service',
  'Customer Inquiry',
] as const

export type RequestType = (typeof REQUEST_TYPES)[number]

export interface CreateRequestInput {
  type: RequestType
  description: string
  orderId?: string
  orderLineItemId?: string
}

export async function createServiceRequest(
  accountId: string,
  userId: string,
  input: CreateRequestInput,
): Promise<{ id: string }> {
  if (!(REQUEST_TYPES as readonly string[]).includes(input.type)) {
    throw new Error('invalid_type')
  }

  const description = input.description.trim()
  if (description.length < 1 || description.length > 4000) {
    throw new Error('invalid_description')
  }

  if (input.orderId !== undefined) {
    const order = await prisma.order.findFirst({
      where: { id: input.orderId, accountId, isDeleted: false },
      select: { id: true },
    })
    if (!order) throw new Error('order_not_found')
  }

  if (input.orderLineItemId !== undefined) {
    // An OLI can only be verified through its parent order. Never pass
    // orderId: undefined into a where-clause — Prisma drops undefined filters,
    // which would silently skip the ownership check.
    const lineItem =
      input.orderId === undefined
        ? null
        : await prisma.orderLineItem.findFirst({
            where: { id: input.orderLineItemId, orderId: input.orderId, isDeleted: false },
            select: { id: true },
          })
    if (!lineItem) throw new Error('line_item_not_found')
  }

  const [created] = await prisma.$transaction([
    prisma.serviceRequest.create({
      data: {
        userId,
        accountId,
        orderId: input.orderId ?? null,
        orderLineItemId: input.orderLineItemId ?? null,
        type: input.type,
        description,
        pushStage: 'pending',
      },
      select: { id: true },
    }),
    prisma.auditLog.create({
      data: {
        action: 'service_request_created',
        userId,
        accountId,
        detail: { type: input.type, orderId: input.orderId ?? null },
      },
    }),
  ])

  return { id: created.id }
}

export interface ServiceRequestSummary {
  id: string
  type: string
  description: string
  pushStage: string
  sfCaseId: string | null
  createdAt: Date
  orderNumber: string | null
}

export async function listServiceRequests(accountId: string): Promise<ServiceRequestSummary[]> {
  const rows = await prisma.serviceRequest.findMany({
    where: { accountId },
    orderBy: { createdAt: 'desc' },
    take: 50,
  })
  const orderIds = [...new Set(rows.map((r) => r.orderId).filter((id): id is string => id !== null))]
  const orders =
    orderIds.length === 0
      ? []
      : await prisma.order.findMany({
          where: { id: { in: orderIds }, accountId }, // accountId re-check: never leak a foreign order number
          select: { id: true, orderNumber: true },
        })
  const numbers = new Map(orders.map((o) => [o.id, o.orderNumber]))
  return rows.map((r) => ({
    id: r.id,
    type: r.type,
    description: r.description,
    pushStage: r.pushStage,
    sfCaseId: r.sfCaseId,
    createdAt: r.createdAt,
    orderNumber: r.orderId ? (numbers.get(r.orderId) ?? null) : null,
  }))
}
```

- [ ] Re-run the integration test: `npx vitest run tests/integration/serviceRequests.test.ts` — expected failure has moved forward: `Failed to resolve import "../../lib/serviceRequests/push"`.

- [ ] Modify `lib/salesforce/mock.ts` (created in Task 5, LIMIT-extended in Task 8) so the mock client is a single memoized instance. All mock state (`store`, `createdRecords`, `idCounter`) already lives at module level, so this changes no behavior — it exists so tests can `vi.spyOn(client, 'create')` and hit the same object production code receives from `getSfClient()`. Replace the entire existing block

  ```ts
  export function getMockSfClient(): SfClient {
    return {
      async queryAll(soqlText: string): Promise<SfRecord[]> {
  ```

  …through the function's closing `}` (it is the last declaration in the file) with:

  ```ts
  // ONE stable instance: tests vi.spyOn(client, 'create') and the spy must hit
  // the same object that lib/serviceRequests/push.ts receives from getSfClient().
  // mockSf.reset() still clears everything because state lives at module level.
  const mockClientInstance: SfClient = {
    async queryAll(soqlText: string): Promise<SfRecord[]> {
      const obj = objectFromSoql(soqlText);
      const rows = store.get(obj) ?? [];
      const floor = modstampFloor(soqlText);
      const filtered = floor ? rows.filter((r) => modstampMs(r) > floor.getTime()) : [...rows];
      const sorted = filtered.sort((a, b) => modstampMs(a) - modstampMs(b));
      const limit = limitClause(soqlText);
      return limit === null ? sorted : sorted.slice(0, limit);
    },
    async create(objectName: string, fields: Record<string, unknown>): Promise<string> {
      createdRecords.push({ obj: objectName, fields });
      idCounter += 1;
      // 'MOCKID' (6 chars) + 12-digit counter = 18-char SF-Id-shaped string
      return `MOCKID${String(idCounter).padStart(12, '0')}`;
    },
  };

  export function getMockSfClient(): SfClient {
    return mockClientInstance;
  }
  ```

  Both method bodies are byte-for-byte what the file contains after Task 8 — Task 5's originals plus Task 8's `limitClause` handling in `queryAll` (which keeps using the module-level `limitClause` helper Task 8 added). Only the object's lifetime changes.

- [ ] Confirm no regression in the mock/client suites: `npx vitest run tests/unit/sf-mock.test.ts tests/unit/sf-client.test.ts` — expected: **PASS (5 + 5 tests)** — sf-mock's four Task 5 tests plus the LIMIT test Task 8 added, and Task 5's five sf-client tests.

- [ ] Create `lib/serviceRequests/push.ts` with exactly:

```ts
import { prisma } from '../db'
import { logger } from '../logger'
import { getSfClient } from '../salesforce/client'

export const CUSTOMER_SUPPORT_RECORD_TYPE_ID = '0123o000001cpHZAAY'

const MAX_ATTEMPTS = 5
const BATCH_SIZE = 20

/**
 * Drains the staged service-request queue into Salesforce as Cases.
 * Idempotent per request: a pushed row leaves 'pending' and is never re-sent.
 * Retry policy: up to MAX_ATTEMPTS with exponential backoff — a request that
 * has failed n times is not retried until 2^n minutes after its last update.
 * Returns counts; `failed` counts only NEWLY-terminal failures from this run.
 */
export async function pushPendingServiceRequests(): Promise<{ pushed: number; failed: number }> {
  const now = new Date()
  const candidates = await prisma.serviceRequest.findMany({
    where: { pushStage: 'pending', attempts: { lt: MAX_ATTEMPTS } },
    orderBy: { createdAt: 'asc' },
    take: BATCH_SIZE,
  })
  const due = candidates.filter(
    (r) => r.attempts === 0 || r.updatedAt <= new Date(now.getTime() - Math.pow(2, r.attempts) * 60_000),
  )

  let pushed = 0
  let failed = 0
  if (due.length === 0) return { pushed, failed }

  const sf = await getSfClient()

  for (const request of due) {
    try {
      const [order, user] = await Promise.all([
        request.orderId
          ? prisma.order.findFirst({
              where: { id: request.orderId, accountId: request.accountId },
              select: { orderNumber: true },
            })
          : Promise.resolve(null),
        prisma.portalUser.findUnique({ where: { id: request.userId }, select: { email: true } }),
      ])
      const orderNumber = order?.orderNumber ?? null
      const email = user?.email ?? 'unknown user'

      const sfCaseId = await sf.create('Case', {
        RecordTypeId: CUSTOMER_SUPPORT_RECORD_TYPE_ID,
        Origin: 'Customer Portal',
        Type: request.type,
        Subject: `[Portal] ${request.type} — ${orderNumber ?? 'General'}`,
        Description: request.description + '\n\n—\nSubmitted via Streamlink by ' + email,
        AccountId: request.accountId,
        ...(request.orderId ? { Customer_Order__c: request.orderId } : {}),
        ...(request.orderLineItemId ? { Order_Line_Item__c: request.orderLineItemId } : {}),
      })

      await prisma.serviceRequest.update({
        where: { id: request.id },
        data: { pushStage: 'pushed', sfCaseId },
      })
      pushed += 1
      logger.info({ serviceRequestId: request.id, sfCaseId }, 'service request pushed to Salesforce')
    } catch (err: unknown) {
      const attempts = request.attempts + 1
      const terminal = attempts >= MAX_ATTEMPTS
      await prisma.serviceRequest.update({
        where: { id: request.id },
        data: {
          attempts,
          lastError: String(err).slice(0, 500),
          pushStage: terminal ? 'failed' : 'pending',
        },
      })
      if (terminal) failed += 1
      logger.warn({ serviceRequestId: request.id, attempts, terminal }, 'service request push failed')
    }
  }

  return { pushed, failed }
}
```

- [ ] Run the integration test: `npx vitest run tests/integration/serviceRequests.test.ts` — expected: **PASS (11 tests)**.

- [ ] Create `jobs/drain.ts` with exactly (mirrors the `jobs/sync.ts` module shape from Task 8, including Task 8's CLI-entry guard, so all job entries are shaped identically):

```ts
import { pathToFileURL } from 'node:url'
import { logger } from '../lib/logger'
import { pushPendingServiceRequests } from '../lib/serviceRequests/push'

/**
 * Service-request drain job: pushes locally-persisted service requests to
 * Salesforce as Cases. Deployed as a Lambda (bundled to index.js, handler =
 * index.handler — see scripts/build-lambdas.mjs, Task 18) on a 5-minute
 * EventBridge schedule, and runnable locally via `npx tsx jobs/drain.ts`.
 */
export async function handler(): Promise<{ pushed: number; failed: number }> {
  const result = await pushPendingServiceRequests()
  logger.info({ result }, 'service-request drain finished')
  return result
}

// CLI entry — true only when this file is the executed script, never on import
// (under vitest/Lambda, argv[1] is the runner, not this file).
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
  handler()
    .then((result) => {
      process.exitCode = result.failed > 0 ? 1 : 0
    })
    .catch((err: unknown) => {
      logger.error({ err }, 'service-request drain crashed')
      process.exitCode = 1
    })
}
```

- [ ] Smoke-run the CLI entry against the local DB: `npx tsx jobs/drain.ts` — expected: one pino JSON line containing `"msg":"service-request drain finished"` with `"result":{"pushed":0,"failed":0}` (nothing pending outside tests), then exit code 0 (`echo $?` → `0`).

- [ ] Write the failing route test. Create `tests/integration/serviceRequestsRoute.test.ts` with exactly (session and email modules are mocked, so the route handler is exercised directly against the local DB; the email spy proves the spec §6 confirmation fires on success and never on failure):

```ts
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

// Must be set before lib/db is loaded, hence the dynamic imports below.
process.env.SF_MODE = 'mock'
process.env.DATABASE_URL ??= 'postgresql://streamlink:streamlink@localhost:5433/streamlink'

vi.mock('../../lib/auth/session', () => ({
  getSession: vi.fn(),
}))
vi.mock('../../lib/auth/email', () => ({
  sendMagicLinkEmail: vi.fn().mockResolvedValue(undefined),
  sendServiceRequestConfirmationEmail: vi.fn().mockResolvedValue(undefined),
}))

const { prisma } = await import('../../lib/db')
const { getSession } = await import('../../lib/auth/session')
const { sendServiceRequestConfirmationEmail } = await import('../../lib/auth/email')
const { POST } = await import('../../app/api/service-requests/route')

const getSessionMock = vi.mocked(getSession)
const confirmationMock = vi.mocked(sendServiceRequestConfirmationEmail)

// All ids are prefixed 't16r-' so cleanup never touches other test files' rows.
const A = 't16r-acct-a'
const USER_A = 't16r-user-a'
const MOD = new Date('2026-07-16T00:00:00Z')

async function cleanup(): Promise<void> {
  await prisma.auditLog.deleteMany({ where: { accountId: A } })
  await prisma.serviceRequest.deleteMany({ where: { accountId: A } })
  await prisma.portalUser.deleteMany({ where: { id: USER_A } })
  await prisma.order.deleteMany({ where: { accountId: A } })
  await prisma.account.deleteMany({ where: { id: A } })
}

function post(body: unknown): Request {
  return new Request('http://localhost/api/service-requests', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
}

/** The confirmation email is fired void-and-caught — flush the microtask queue before asserting. */
async function flushFireAndForget(): Promise<void> {
  await new Promise((resolve) => setImmediate(resolve))
}

beforeEach(async () => {
  vi.clearAllMocks()
  await cleanup()
  await prisma.account.create({
    data: { id: A, name: 'Acme Co', recordType: 'Business_Account', sfModstamp: MOD },
  })
  await prisma.order.create({
    data: { id: 't16r-order-a1', accountId: A, orderNumber: 'CO-9001', customerStatus: 'open', sfModstamp: MOD },
  })
  await prisma.portalUser.create({ data: { id: USER_A, email: 't16r-user-a@example.com' } })
  getSessionMock.mockResolvedValue({
    userId: USER_A,
    email: 't16r-user-a@example.com',
    accountId: A,
    accountName: 'Acme Co',
  })
})

afterAll(async () => {
  await cleanup()
  await prisma.$disconnect()
})

describe('POST /api/service-requests', () => {
  it('creates the request and fires the confirmation email with the order number', async () => {
    const res = await POST(post({ type: 'Swap Dumpster', description: 'Please swap it.', orderId: 't16r-order-a1' }))
    expect(res.status).toBe(201)
    const body = (await res.json()) as { id: string }
    expect(body.id).toBeTruthy()

    await flushFireAndForget()
    expect(confirmationMock).toHaveBeenCalledTimes(1)
    expect(confirmationMock).toHaveBeenCalledWith('t16r-user-a@example.com', 'Swap Dumpster', 'CO-9001')
  })

  it('passes a null order number when the request has no order', async () => {
    const res = await POST(post({ type: 'Customer Inquiry', description: 'Question about my account.' }))
    expect(res.status).toBe(201)
    await flushFireAndForget()
    expect(confirmationMock).toHaveBeenCalledWith('t16r-user-a@example.com', 'Customer Inquiry', null)
  })

  it('rejects a whitespace-only description with 400 invalid_body, persists nothing, sends no email', async () => {
    const res = await POST(post({ type: 'Service Issue', description: '   ' }))
    expect(res.status).toBe(400)
    expect(await res.json()).toEqual({ error: 'invalid_body' })
    await flushFireAndForget()
    expect(confirmationMock).not.toHaveBeenCalled()
    expect(await prisma.serviceRequest.count({ where: { accountId: A } })).toBe(0)
  })

  it('rejects an unknown type with 400 invalid_body and sends no email', async () => {
    const res = await POST(post({ type: 'Pizza Delivery', description: 'hello' }))
    expect(res.status).toBe(400)
    expect(await res.json()).toEqual({ error: 'invalid_body' })
    await flushFireAndForget()
    expect(confirmationMock).not.toHaveBeenCalled()
  })

  it('returns 404 order_not_found for an unknown order and sends no email', async () => {
    const res = await POST(post({ type: 'Removal Request', description: 'take it away', orderId: 'no-such-order' }))
    expect(res.status).toBe(404)
    expect(await res.json()).toEqual({ error: 'order_not_found' })
    await flushFireAndForget()
    expect(confirmationMock).not.toHaveBeenCalled()
  })
})
```

- [ ] Run it and confirm the failure: `npx vitest run tests/integration/serviceRequestsRoute.test.ts` — expected failure: `Failed to resolve import "../../app/api/service-requests/route"` (the route does not exist yet; the mocked email module already satisfies the `sendServiceRequestConfirmationEmail` import).

- [ ] Modify `lib/auth/email.ts` (Task 10) — spec §6 promises a confirmation email when a service request is submitted. Append this function at the end of the file (it reuses the module's existing `logger`, `getSesClient()` and `SendEmailCommand` — no new imports; same `EMAIL_MODE` `log`|`ses` plumbing as `sendMagicLinkEmail`):

```ts
export async function sendServiceRequestConfirmationEmail(
  to: string,
  requestType: string,
  orderNumber: string | null,
): Promise<void> {
  const mode = process.env.EMAIL_MODE
  if (mode !== 'ses') {
    // EMAIL_MODE=log is the default for dev/test.
    logger.info({ to, requestType, orderNumber }, 'service request confirmation')
    return
  }

  const from = process.env.SES_FROM_ADDRESS
  if (!from) {
    throw new Error('SES_FROM_ADDRESS must be set when EMAIL_MODE=ses')
  }

  // requestType is one of the fixed REQUEST_TYPES and orderNumber comes from the
  // portal database — both trusted values; no user-typed text is embedded.
  const orderSuffix = orderNumber === null ? '' : ` for order ${orderNumber}`
  const html = [
    '<div style="font-family: Arial, Helvetica, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">',
    '<h1 style="font-size: 20px; color: #1a1a2e; margin: 0 0 16px;">Streamlink by LDR Site Services</h1>',
    `<p style="font-size: 15px; color: #333333; margin: 0 0 8px;">We received your <strong>${requestType}</strong> request${orderSuffix} and our support team is on it.</p>`,
    '<p style="font-size: 15px; color: #333333; margin: 0 0 8px;">You can track its status any time on the Support page of your portal.</p>',
    '<p style="font-size: 13px; color: #666666;">If you did not submit this request, please contact us.</p>',
    '</div>',
  ].join('\n')
  const text = [
    'Streamlink by LDR Site Services',
    '',
    `We received your ${requestType} request${orderSuffix} and our support team is on it.`,
    'You can track its status any time on the Support page of your portal.',
    '',
    'If you did not submit this request, please contact us.',
  ].join('\n')

  await getSesClient().send(
    new SendEmailCommand({
      Source: from,
      Destination: { ToAddresses: [to] },
      Message: {
        Subject: { Data: `We received your ${requestType} request`, Charset: 'UTF-8' },
        Body: {
          Html: { Data: html, Charset: 'UTF-8' },
          Text: { Data: text, Charset: 'UTF-8' },
        },
      },
    }),
  )
}
```

  Then confirm Task 10's email suite still passes: `npx vitest run tests/unit/email.test.ts` — expected: PASS (4 tests, untouched).

- [ ] Create `app/api/service-requests/route.ts` with exactly:

```ts
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { sendServiceRequestConfirmationEmail } from '../../../lib/auth/email'
import { getSession } from '../../../lib/auth/session'
import { prisma } from '../../../lib/db'
import { logger } from '../../../lib/logger'
import { createServiceRequest, REQUEST_TYPES } from '../../../lib/serviceRequests/create'

const bodySchema = z.object({
  type: z.enum(REQUEST_TYPES),
  // .trim() before .min(1): a whitespace-only description must fail here as
  // invalid_body, not reach the domain layer and surface as a 500.
  description: z.string().trim().min(1).max(4000),
  orderId: z.string().optional(),
  orderLineItemId: z.string().optional(),
})

export async function POST(request: Request): Promise<NextResponse> {
  const session = await getSession()
  if (!session || !session.accountId) {
    return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
  }

  const json: unknown = await request.json().catch(() => null)
  const parsed = bodySchema.safeParse(json)
  if (!parsed.success) {
    return NextResponse.json({ error: 'invalid_body' }, { status: 400 })
  }

  try {
    const { id } = await createServiceRequest(session.accountId, session.userId, parsed.data)

    // Spec §6: confirmation email on submission. The order was already
    // ownership-validated inside createServiceRequest; this lookup only fetches
    // its display number. Fire-and-forget: an email hiccup must never fail a
    // request that is already persisted.
    const orderNumber = parsed.data.orderId
      ? ((
          await prisma.order.findFirst({
            where: { id: parsed.data.orderId, accountId: session.accountId },
            select: { orderNumber: true },
          })
        )?.orderNumber ?? null)
      : null
    void sendServiceRequestConfirmationEmail(session.email, parsed.data.type, orderNumber).catch(
      (err: unknown) => logger.error({ err }, 'confirmation email failed'),
    )

    return NextResponse.json({ id }, { status: 201 })
  } catch (err: unknown) {
    if (err instanceof Error && (err.message === 'invalid_type' || err.message === 'invalid_description')) {
      // Defense in depth: zod already blocks these shapes, but the domain
      // layer's own validation must map to 400, never bubble up as a 500.
      return NextResponse.json({ error: 'invalid_body' }, { status: 400 })
    }
    if (err instanceof Error && (err.message === 'order_not_found' || err.message === 'line_item_not_found')) {
      return NextResponse.json({ error: err.message }, { status: 404 })
    }
    throw err
  }
}
```

- [ ] Run the route test: `npx vitest run tests/integration/serviceRequestsRoute.test.ts` — expected: **PASS (5 tests)**.

- [ ] Create `app/(portal)/support/new/NewRequestForm.tsx` with exactly (client component; label/`htmlFor` pairs are load-bearing — Task 17's e2e locates the fields with `getByLabel(/^type/i)` and `getByLabel(/^description/i)` and the button with `getByRole('button', { name: /submit/i })`):

```tsx
'use client'

import { useRouter } from 'next/navigation'
import { useState } from 'react'
import type { FormEvent } from 'react'

const MAX_DESCRIPTION = 4000

export interface OrderOptionGroup {
  siteLabel: string
  options: { id: string; label: string }[]
}

const ERROR_MESSAGES: Record<string, string> = {
  unauthorized: 'Your session has expired — please log in again.',
  invalid_body: 'Please choose a request type and enter a description.',
  order_not_found: 'That order could not be found on your account.',
  line_item_not_found: 'That unit could not be found on the selected order.',
}

export function NewRequestForm({
  requestTypes,
  orderGroups,
}: {
  requestTypes: readonly string[]
  orderGroups: OrderOptionGroup[]
}) {
  const router = useRouter()
  const [type, setType] = useState<string>(requestTypes[0] ?? '')
  const [orderId, setOrderId] = useState('')
  const [description, setDescription] = useState('')
  const [submitting, setSubmitting] = useState(false)
  const [error, setError] = useState<string | null>(null)

  async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
    event.preventDefault()
    setSubmitting(true)
    setError(null)
    try {
      const response = await fetch('/api/service-requests', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ type, description, ...(orderId !== '' ? { orderId } : {}) }),
      })
      if (response.ok) {
        router.push('/support?submitted=1')
        router.refresh()
        return
      }
      const body = (await response.json().catch(() => null)) as { error?: string } | null
      setError(ERROR_MESSAGES[body?.error ?? ''] ?? 'Something went wrong — please try again.')
      setSubmitting(false)
    } catch {
      setError('Something went wrong — please try again.')
      setSubmitting(false)
    }
  }

  return (
    <form onSubmit={handleSubmit} className="mt-6 max-w-xl space-y-5">
      <div>
        <label htmlFor="type" className="block text-sm font-medium text-slate-700">
          Type
        </label>
        <select
          id="type"
          value={type}
          onChange={(event) => setType(event.target.value)}
          className="mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm"
        >
          {requestTypes.map((requestType) => (
            <option key={requestType} value={requestType}>
              {requestType}
            </option>
          ))}
        </select>
      </div>

      <div>
        <label htmlFor="order" className="block text-sm font-medium text-slate-700">
          Related order (optional)
        </label>
        <select
          id="order"
          value={orderId}
          onChange={(event) => setOrderId(event.target.value)}
          className="mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm"
        >
          <option value="">No specific order</option>
          {orderGroups.map((group, groupIndex) => (
            <optgroup key={`${group.siteLabel}-${groupIndex}`} label={group.siteLabel}>
              {group.options.map((option) => (
                <option key={option.id} value={option.id}>
                  {option.label}
                </option>
              ))}
            </optgroup>
          ))}
        </select>
      </div>

      <div>
        <label htmlFor="description" className="block text-sm font-medium text-slate-700">
          Description
        </label>
        <textarea
          id="description"
          required
          maxLength={MAX_DESCRIPTION}
          rows={6}
          value={description}
          onChange={(event) => setDescription(event.target.value)}
          className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
          placeholder="Tell us what you need — include gate codes, timing, or anything the driver should know."
        />
        <p className="mt-1 text-right text-xs text-slate-400">
          {description.length}/{MAX_DESCRIPTION}
        </p>
      </div>

      {error && (
        <p role="alert" className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
          {error}
        </p>
      )}

      <button
        type="submit"
        disabled={submitting}
        className="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:opacity-50"
      >
        {submitting ? 'Submitting…' : 'Submit request'}
      </button>
    </form>
  )
}
```

- [ ] Create `app/(portal)/support/new/page.tsx` with exactly:

```tsx
import { requireSession } from '../../../../lib/auth/session'
import { getSites } from '../../../../lib/domain/orders'
import { REQUEST_TYPES } from '../../../../lib/serviceRequests/create'
import { NewRequestForm } from './NewRequestForm'
import type { OrderOptionGroup } from './NewRequestForm'

export default async function NewRequestPage() {
  const session = await requireSession()
  const sites = await getSites(session.accountId)

  // REQUEST_TYPES lives in lib/serviceRequests/create.ts, which imports Prisma.
  // It is imported HERE on the server and passed down as a plain prop — the
  // client component must never import that module.
  const orderGroups: OrderOptionGroup[] = sites
    .map((site) => ({
      siteLabel: site.siteLabel,
      options: site.orders.map((order) => ({
        id: order.id,
        label: `${order.orderNumber} — ${site.siteLabel}`,
      })),
    }))
    .filter((group) => group.options.length > 0)

  return (
    <div className="mx-auto max-w-3xl">
      <h1 className="text-2xl font-semibold tracking-tight">New service request</h1>
      <p className="mt-1 text-sm text-slate-500">
        Tell us what you need and we&apos;ll take it from there. Requests go straight to our support team.
      </p>
      <NewRequestForm requestTypes={REQUEST_TYPES} orderGroups={orderGroups} />
    </div>
  )
}
```

- [ ] Modify `app/(portal)/support/page.tsx` — replace the entire file (Task 15 left the insertion-point comment for exactly this change; the tickets `<section>` below is byte-for-byte Task 15's) with:

```tsx
import Link from 'next/link'
import { requireSession } from '../../../lib/auth/session'
import { getCases } from '../../../lib/domain/support'
import { listServiceRequests } from '../../../lib/serviceRequests/create'
import { DateText, StatusPill } from '../ui'

function truncateDescription(text: string): string {
  return text.length <= 60 ? text : `${text.slice(0, 60)}…`
}

function RequestChip({ pushStage }: { pushStage: string }) {
  if (pushStage === 'pushed') {
    return (
      <span className="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">
        Received
      </span>
    )
  }
  if (pushStage === 'failed') {
    return (
      <span className="inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800">
        Needs attention — call us
      </span>
    )
  }
  return (
    <span className="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-800">
      Submitted
    </span>
  )
}

export default async function SupportPage({
  searchParams,
}: {
  searchParams?: { submitted?: string | string[] }
}) {
  const session = await requireSession()
  const [cases, requests] = await Promise.all([
    getCases(session.accountId),
    listServiceRequests(session.accountId),
  ])
  // sfCaseId is an SF Case Id; once the 2-minute sync mirrors that Case into
  // the local cases table we can show its human case number. Deliberately NO
  // link to /support/{id} — the mirror may not exist yet.
  const caseNumberBySfId = new Map(cases.map((c) => [c.id, c.caseNumber]))
  const justSubmitted = searchParams?.submitted === '1'

  return (
    <div className="mx-auto max-w-5xl">
      <div className="flex flex-wrap items-center justify-between gap-3">
        <h1 className="text-2xl font-semibold tracking-tight">Support</h1>
        <Link
          href="/support/new"
          className="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700"
        >
          New request
        </Link>
      </div>

      {justSubmitted && (
        <div
          role="status"
          className="mt-4 rounded-md border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800"
        >
          Request submitted — we&apos;re on it.
        </div>
      )}

      {requests.length > 0 && (
        <section className="mt-8">
          <h2 className="text-lg font-semibold">Your requests</h2>
          <div className="mt-3 overflow-x-auto rounded-lg border border-slate-200 bg-white">
            <table className="w-full text-sm">
              <thead>
                <tr className="text-left text-xs uppercase tracking-wide text-slate-400">
                  <th className="px-5 py-2 font-medium">Type</th>
                  <th className="px-5 py-2 font-medium">Description</th>
                  <th className="px-5 py-2 font-medium">Status</th>
                  <th className="px-5 py-2 font-medium">Created</th>
                </tr>
              </thead>
              <tbody className="divide-y divide-slate-100">
                {requests.map((request) => (
                  <tr key={request.id}>
                    <td className="px-5 py-3 font-medium text-slate-900">{request.type}</td>
                    <td className="px-5 py-3 text-slate-600">{truncateDescription(request.description)}</td>
                    <td className="px-5 py-3">
                      <RequestChip pushStage={request.pushStage} />
                      {request.pushStage === 'pushed' &&
                        request.sfCaseId !== null &&
                        caseNumberBySfId.has(request.sfCaseId) && (
                          <span className="ml-2 text-xs text-slate-500">
                            Case {caseNumberBySfId.get(request.sfCaseId)}
                          </span>
                        )}
                    </td>
                    <td className="px-5 py-3 text-slate-600">
                      <DateText value={request.createdAt} />
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </section>
      )}

      <section className="mt-8">
        <h2 className="text-lg font-semibold">Support tickets</h2>
        {cases.length === 0 ? (
          <p className="mt-3 text-sm text-slate-500">No support tickets yet.</p>
        ) : (
          <div className="mt-3 overflow-x-auto rounded-lg border border-slate-200 bg-white">
            <table className="w-full text-sm">
              <thead>
                <tr className="text-left text-xs uppercase tracking-wide text-slate-400">
                  <th className="px-5 py-2 font-medium">Case</th>
                  <th className="px-5 py-2 font-medium">Subject</th>
                  <th className="px-5 py-2 font-medium">Type</th>
                  <th className="px-5 py-2 font-medium">Status</th>
                  <th className="px-5 py-2 font-medium">Created</th>
                  <th className="px-5 py-2 font-medium">Order</th>
                </tr>
              </thead>
              <tbody className="divide-y divide-slate-100">
                {cases.map((c) => (
                  <tr key={c.id}>
                    <td className="px-5 py-3">
                      <Link
                        href={`/support/${c.id}`}
                        className="font-medium text-slate-900 underline decoration-slate-300 underline-offset-2 hover:decoration-slate-900"
                      >
                        {c.caseNumber}
                      </Link>
                    </td>
                    <td className="px-5 py-3 text-slate-700">{c.subject ?? '—'}</td>
                    <td className="px-5 py-3 text-slate-600">{c.type ?? '—'}</td>
                    <td className="px-5 py-3">
                      <StatusPill status={c.status} />
                    </td>
                    <td className="px-5 py-3 text-slate-600">
                      <DateText value={c.createdAt} />
                    </td>
                    <td className="px-5 py-3 text-slate-600">{c.orderNumber ?? '—'}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </section>
    </div>
  )
}
```

- [ ] Typecheck everything: `npx tsc --noEmit` — expected: no errors.

- [ ] Run the full suite (DB up): `npm test` — expected: PASS, including the sf-mock/sf-client suites, Task 10's email tests, Task 15's support tests, and the new route test — no regressions.

- [ ] Optional manual check (requires Task 17's seed script; skip until then): `npx tsx scripts/seed-dev.ts && npm run dev`, log in with the seeded dev user, submit a request at `/support/new` → redirected to `/support?submitted=1` with the green toast and an amber "Submitted" chip (with `EMAIL_MODE=log`, the dev server also logs one `service request confirmation` line); then run `npx tsx jobs/drain.ts` and reload — the chip turns green "Received" (mock SF assigns `MOCKID…`).

- [ ] Commit:

```
git add lib/serviceRequests/create.ts lib/serviceRequests/push.ts lib/salesforce/mock.ts lib/auth/email.ts jobs/drain.ts app/api/service-requests/route.ts "app/(portal)/support/new/page.tsx" "app/(portal)/support/new/NewRequestForm.tsx" "app/(portal)/support/page.tsx" tests/integration/serviceRequests.test.ts tests/integration/serviceRequestsRoute.test.ts
git commit -m "feat: service requests end-to-end with staged salesforce case push and confirmation email"
```
### Task 17: Dev Seed Script + Playwright E2E Suite

**Files:**
- Create: `scripts/seed-dev.ts`
- Create: `tests/integration/seed.test.ts`
- Create: `playwright.config.ts`
- Create: `tests/e2e/portal.spec.ts`
- Modify: `package.json` (add `db:seed`, `e2e` scripts)
- Modify: `vitest.config.ts` (ensure `tests/e2e/` is excluded from vitest)
- Modify: `.gitignore` (Playwright output dirs)
- Modify: `app/(portal)/dashboard/page.tsx` (add `data-testid` hooks)
- Modify: `app/(portal)/sites/page.tsx` (add `data-testid` hook)
- Modify: `app/(portal)/orders/[id]/page.tsx` (add `data-testid` hook)
- Test: `tests/integration/seed.test.ts`, `tests/e2e/portal.spec.ts`

**Interfaces:**

Consumes (from earlier tasks):
- `lib/db.ts`: `export const prisma: PrismaClient` (singleton) and the full Prisma schema from Task 2 (all mirror + portal tables, exact model/field names from the plan header).
- Docker Postgres from Task 2 on `localhost:5433`, database `streamlink` (`npm run db:up`, `npm run db:migrate`).
- Auth stack: `GET /auth/verify?token=<raw>` route, which calls `verifyMagicLink(rawToken): Promise<{ sessionToken: string; accountIds: string[] } | null>`, sets the `sl_session` cookie, and — because `demo@acme.test` maps to exactly one account — redirects to `/dashboard`. Magic-link tokens are stored as **sha256 hex digests** in `magic_links.token_hash` (the e2e helper reproduces this hashing).
- Portal pages: `/dashboard`, `/sites`, `/orders/[id]`, `/billing`, `/support`, `/support/new` (form with a labeled "Type" select over `REQUEST_TYPES` and a labeled "Description" textarea, posting to `POST /api/service-requests`).
- Domain functions whose outputs the seeded numbers must satisfy: `getDashboard(accountId): Promise<DashboardData>`, `getSites(accountId): Promise<SiteGroup[]>`, `getOrderDetail(accountId, orderId): Promise<OrderDetail | null>` (returns `null` → 404 for another account's order), `getBilling(accountId): Promise<BillingData>`, `getCases(accountId): Promise<CaseSummary[]>`, `getLastSuccessfulSyncAt(): Promise<Date | null>`.

Produces (later tasks rely on):
- `export async function seed(): Promise<void>` and `export const SEED` (fixture id constants) from `scripts/seed-dev.ts`.
- `npm run db:seed` and `npm run e2e` — Task 18's root README quickstart references both, plus the demo login email `demo@acme.test`.
- Test hooks contract on the portal pages: `data-testid="stat-active-units" | "stat-active-sites" | "stat-open-balance"` (dashboard), `data-testid="site-group"` (one per site group on `/sites`), `data-testid="unit-card"` (one per placement unit — never on adjustments — on the order detail page).

**Fixture dataset (deterministic — every id is a stable 18-char SF-shaped string):**

| Entity | Key values | Expected derived numbers |
|---|---|---|
| Account `001SEEDACME0000001` "Acme Facilities" | 2 sites × 2 orders | — |
| Account `001SEEDOTHER000001` "Other Co" | 1 order — isolation foil | must never render for Acme |
| Contacts | `demo@acme.test` → Acme; `foil@other.test` → Other Co | single-account login → `/dashboard` |
| Orders (Acme) | ORD-1001 (open, bal 150.00, site "Acme Store #101"), ORD-1002 (open, bal 325.50, site #101), ORD-1003 (open, bal 0.00, site #202), ORD-1004 (closed, bal 0.00, site #202) | site groups = **2** |
| Order (foil) | ORD-9001 = `a00SEEDOTHER000001` | direct URL → **404** |
| OLIs | ORD-1001: 1 placement (`delivered`, 20 yd) **+ 1 `Additional Tonnage` adjustment (85.00)**; ORD-1002: `scheduled`; ORD-1003: `removal_requested`; ORD-1004: `completed` | active units = **3**; unit cards on ORD-1001 = **1** (no phantom unit) |
| Invoices | INV-2001 `Open`, amountDue **475.50**; INV-2002 `Paid In Full`, due 0.00 | open-order balances (150.00+325.50) ≡ open-invoice due ≡ **475.50** — dashboard `openBalance` is `'475.50'` under either derivation |
| Transactions | T-000001 (payment 100.00), T-000002 (payment 24.50), T-000003 (**Void**, `isVoided`, `countsTowardTotals=false`) | billing shows 2 payments, never T-000003 |
| Cases | 00001001 open (Swap Dumpster), 00001002 closed (Delivery ETA) | `/support` lists both |
| SavedCard | "Corporate Visa" ····4242, primary | shown on `/billing` |
| DeliveryConfirmation | on ORD-1001's placement, responded "yes" | feeds dashboard activity |
| SyncRun | `status='success'`, `finishedAt=now` | staleness banner **absent** |

**Steps:**

- [ ] Start the local database and apply migrations (both from Task 2):

  ```bash
  npm run db:up
  npm run db:migrate
  ```

- [ ] Write the failing integration test `tests/integration/seed.test.ts` (complete file):

  ```ts
  // tests/integration/seed.test.ts
  // Requires local Postgres (npm run db:up). Verifies the dev seed is
  // idempotent and produces the exact fixture shape the e2e suite pins.
  import { beforeAll, describe, expect, it } from 'vitest'
  import { prisma } from '../../lib/db'
  import { SEED, seed } from '../../scripts/seed-dev'

  describe('seed-dev', () => {
    beforeAll(async () => {
      await seed()
      await seed() // idempotency: second run must not throw or duplicate
    })

    it('produces exact row counts after repeated runs', async () => {
      expect(await prisma.account.count()).toBe(2)
      expect(await prisma.contact.count()).toBe(2)
      expect(await prisma.order.count()).toBe(5)
      expect(await prisma.orderLineItem.count()).toBe(6)
      expect(await prisma.caseRecord.count()).toBe(2)
      expect(await prisma.transaction.count()).toBe(3)
      expect(await prisma.invoice.count()).toBe(2)
      expect(await prisma.deliveryConfirmation.count()).toBe(1)
      expect(await prisma.savedCard.count()).toBe(1)
      expect(await prisma.syncRun.count()).toBe(1)
      expect(await prisma.portalUser.count()).toBe(0) // portal users are created by login, not seed
    })

    it('keeps the foil account isolated from Acme', async () => {
      const acmeOrders = await prisma.order.findMany({ where: { accountId: SEED.acmeAccountId } })
      expect(acmeOrders).toHaveLength(4)
      const foil = await prisma.order.findUniqueOrThrow({ where: { id: SEED.orderOther } })
      expect(foil.accountId).toBe(SEED.otherAccountId)
    })

    it('marks the Additional Tonnage line as a charge adjustment, not a unit', async () => {
      const adj = await prisma.orderLineItem.findUniqueOrThrow({ where: { id: SEED.oliA1Adj } })
      expect(adj.isChargeAdjustment).toBe(true)
      expect(adj.recordType).toBe('Additional Tonnage')
      expect(adj.orderId).toBe(SEED.orderA1)
      expect(adj.lineTotal?.toFixed(2)).toBe('85.00')
    })

    it('seeds mixed unit statuses and consistent balances', async () => {
      const statuses = (
        await prisma.orderLineItem.findMany({
          where: { order: { accountId: SEED.acmeAccountId }, isChargeAdjustment: false },
          select: { customerStatus: true },
        })
      ).map((r) => r.customerStatus).sort()
      expect(statuses).toEqual(['completed', 'delivered', 'removal_requested', 'scheduled'])
      const openInvoice = await prisma.invoice.findUniqueOrThrow({ where: { id: SEED.invoiceOpen } })
      expect(openInvoice.status).toBe('Open')
      expect(openInvoice.amountDue?.toFixed(2)).toBe('475.50')
    })

    it('records a fresh successful sync run so the staleness banner stays quiet', async () => {
      const run = await prisma.syncRun.findFirstOrThrow({ where: { status: 'success' } })
      expect(run.finishedAt).not.toBeNull()
      expect(Date.now() - (run.finishedAt as Date).getTime()).toBeLessThan(60_000)
    })

    it('flags the void transaction out of totals', async () => {
      const voided = await prisma.transaction.findUniqueOrThrow({ where: { id: SEED.txnVoid } })
      expect(voided.isVoided).toBe(true)
      expect(voided.countsTowardTotals).toBe(false)
    })
  })
  ```

- [ ] Run it and watch it fail because the seed script does not exist yet:

  ```bash
  npx vitest run tests/integration/seed.test.ts
  ```

  Expected failure: `Failed to resolve import "../../scripts/seed-dev" from "tests/integration/seed.test.ts"` (module not found).

- [ ] Create `scripts/seed-dev.ts` (complete file):

  ```ts
  // scripts/seed-dev.ts
  //
  // Idempotent local-dev seed. Wipes every portal + mirror table (children
  // before parents), then inserts a deterministic fixture set:
  //   - "Acme Facilities": 2 sites x 2 orders, mixed unit statuses; ORD-1001
  //     carries 1 placement OLI + 1 "Additional Tonnage" charge adjustment.
  //   - "Other Co": 1 order — the cross-account isolation foil.
  //   - Billing (1 open + 1 paid invoice, 2 payments + 1 void), a saved card,
  //     a delivery confirmation, 2 cases, and a fresh successful SyncRun so
  //     the staleness banner stays quiet.
  //
  // Run:    npm run db:seed          (tsx scripts/seed-dev.ts)
  // Import: import { SEED, seed } from '../scripts/seed-dev'
  //
  // NOTE: lib/db is imported dynamically inside getDb() — never statically.
  // ESM static imports are hoisted, and the DATABASE_URL fallback below must
  // execute before PrismaClient is constructed.

  process.env.DATABASE_URL ??=
    'postgresql://streamlink:streamlink@localhost:5433/streamlink' // matches docker-compose.yml (Task 2)

  import type { PrismaClient } from '@prisma/client' // type-only: erased at runtime, not hoisted

  // ── stable 18-char SF-shaped fixture ids ──
  export const SEED = {
    acmeAccountId: '001SEEDACME0000001',
    otherAccountId: '001SEEDOTHER000001',
    acmeContactId: '003SEEDACME0000001',
    otherContactId: '003SEEDOTHER000001',
    orderA1: 'a00SEEDACME1000001', // ORD-1001 — placement + Additional Tonnage adjustment
    orderA2: 'a00SEEDACME1000002', // ORD-1002 — scheduled delivery
    orderB1: 'a00SEEDACME1000003', // ORD-1003 — removal requested
    orderB2: 'a00SEEDACME1000004', // ORD-1004 — closed / completed
    orderOther: 'a00SEEDOTHER000001', // ORD-9001 — the isolation foil
    oliA1Unit: 'a01SEEDACME2000001',
    oliA1Adj: 'a01SEEDACME2000002',
    oliA2Unit: 'a01SEEDACME2000003',
    oliB1Unit: 'a01SEEDACME2000004',
    oliB2Unit: 'a01SEEDACME2000005',
    oliOther: 'a01SEEDOTHER000001',
    caseOpen: '500SEEDACME3000001',
    caseClosed: '500SEEDACME3000002',
    txnPayment1: 'a02SEEDACME4000001',
    txnPayment2: 'a02SEEDACME4000002',
    txnVoid: 'a02SEEDACME4000003',
    invoiceOpen: 'a03SEEDACME5000001',
    invoicePaid: 'a03SEEDACME5000002',
    deliveryConf: 'a04SEEDACME6000001',
    savedCard: 'a05SEEDACME7000001',
  } as const

  async function getDb(): Promise<PrismaClient> {
    const { prisma } = await import('../lib/db')
    return prisma
  }

  export async function seed(): Promise<void> {
    const prisma = await getDb()
    const now = new Date()
    const daysAgo = (n: number): Date => new Date(now.getTime() - n * 86_400_000)
    const daysFromNow = (n: number): Date => new Date(now.getTime() + n * 86_400_000)

    // 1) Wipe, children before parents.
    // FKs: ServiceRequest→PortalUser, Session→PortalUser,
    //      OrderLineItem→Order, Contact→Account, Order→Account.
    await prisma.serviceRequest.deleteMany()
    await prisma.session.deleteMany()
    await prisma.magicLink.deleteMany()
    await prisma.auditLog.deleteMany()
    await prisma.portalUser.deleteMany()
    await prisma.deliveryConfirmation.deleteMany()
    await prisma.transaction.deleteMany()
    await prisma.invoice.deleteMany()
    await prisma.caseRecord.deleteMany()
    await prisma.savedCard.deleteMany()
    await prisma.orderLineItem.deleteMany()
    await prisma.order.deleteMany()
    await prisma.contact.deleteMany()
    await prisma.account.deleteMany()
    await prisma.syncRun.deleteMany()
    await prisma.syncState.deleteMany()

    // 2) Accounts + contacts
    await prisma.account.createMany({
      data: [
        { id: SEED.acmeAccountId, name: 'Acme Facilities', recordType: 'Business_Account', sfModstamp: daysAgo(1) },
        { id: SEED.otherAccountId, name: 'Other Co', recordType: 'Business_Account', sfModstamp: daysAgo(1) },
      ],
    })
    await prisma.contact.createMany({
      data: [
        {
          id: SEED.acmeContactId,
          accountId: SEED.acmeAccountId,
          email: 'demo@acme.test',
          firstName: 'Dana',
          lastName: 'Demo',
          phone: '512-555-0101',
          sfModstamp: daysAgo(1),
        },
        {
          id: SEED.otherContactId,
          accountId: SEED.otherAccountId,
          email: 'foil@other.test',
          firstName: 'Frank',
          lastName: 'Foil',
          sfModstamp: daysAgo(1),
        },
      ],
    })

    // 3) Orders — Acme: site "Acme Store #101" (A1, A2) and "#202" (B1, B2)
    await prisma.order.createMany({
      data: [
        {
          id: SEED.orderA1,
          accountId: SEED.acmeAccountId,
          orderNumber: 'ORD-1001',
          company: 'LDR Site Services',
          statusRaw: 'Open',
          customerStatus: 'open',
          storeName: 'Acme Store',
          storeNumber: '101',
          shippingStreet: '100 Congress Ave',
          shippingCity: 'Austin',
          shippingState: 'TX',
          shippingZip: '78701',
          siteAddress: '100 Congress Ave, Austin, TX 78701',
          deliveryDate: daysAgo(10),
          firstDeliveryDate: daysAgo(10),
          lineTotal: '550.00',
          taxAmount: '50.00',
          totalAfterTax: '600.00',
          chargedAmount: '450.00',
          balance: '150.00',
          customerPo: 'PO-77812',
          notesToCustomer: 'Gate code 4477.',
          sfModstamp: daysAgo(1),
        },
        {
          id: SEED.orderA2,
          accountId: SEED.acmeAccountId,
          orderNumber: 'ORD-1002',
          company: 'LDR Site Services',
          statusRaw: 'Open',
          customerStatus: 'open',
          storeName: 'Acme Store',
          storeNumber: '101',
          shippingStreet: '100 Congress Ave',
          shippingCity: 'Austin',
          shippingState: 'TX',
          shippingZip: '78701',
          siteAddress: '100 Congress Ave, Austin, TX 78701',
          deliveryDate: daysFromNow(3),
          lineTotal: '325.50',
          taxAmount: '0.00',
          totalAfterTax: '325.50',
          chargedAmount: '0.00',
          balance: '325.50',
          sfModstamp: daysAgo(1),
        },
        {
          id: SEED.orderB1,
          accountId: SEED.acmeAccountId,
          orderNumber: 'ORD-1003',
          company: 'LDR Site Services',
          statusRaw: 'Open',
          customerStatus: 'open',
          storeName: 'Acme Store',
          storeNumber: '202',
          shippingStreet: '2200 S Lamar Blvd',
          shippingCity: 'Austin',
          shippingState: 'TX',
          shippingZip: '78704',
          siteAddress: '2200 S Lamar Blvd, Austin, TX 78704',
          deliveryDate: daysAgo(30),
          firstDeliveryDate: daysAgo(30),
          lineTotal: '410.00',
          taxAmount: '0.00',
          totalAfterTax: '410.00',
          chargedAmount: '410.00',
          balance: '0.00',
          sfModstamp: daysAgo(2),
        },
        {
          id: SEED.orderB2,
          accountId: SEED.acmeAccountId,
          orderNumber: 'ORD-1004',
          company: 'LDR Site Services',
          statusRaw: 'Closed',
          customerStatus: 'closed',
          storeName: 'Acme Store',
          storeNumber: '202',
          shippingStreet: '2200 S Lamar Blvd',
          shippingCity: 'Austin',
          shippingState: 'TX',
          shippingZip: '78704',
          siteAddress: '2200 S Lamar Blvd, Austin, TX 78704',
          deliveryDate: daysAgo(90),
          firstDeliveryDate: daysAgo(90),
          latestEndDate: daysAgo(60),
          completedDate: daysAgo(60),
          lineTotal: '298.00',
          taxAmount: '0.00',
          totalAfterTax: '298.00',
          chargedAmount: '298.00',
          balance: '0.00',
          sfModstamp: daysAgo(60),
        },
        {
          id: SEED.orderOther,
          accountId: SEED.otherAccountId,
          orderNumber: 'ORD-9001',
          company: 'LDR Site Services',
          statusRaw: 'Open',
          customerStatus: 'open',
          siteAddress: '9 Elsewhere Rd, Dallas, TX 75201',
          shippingCity: 'Dallas',
          shippingState: 'TX',
          deliveryDate: daysAgo(5),
          lineTotal: '75.00',
          balance: '75.00',
          sfModstamp: daysAgo(1),
        },
      ],
    })

    // 4) Order line items — statuses consistent with mapUnitStatus() inputs
    await prisma.orderLineItem.createMany({
      data: [
        {
          id: SEED.oliA1Unit,
          orderId: SEED.orderA1,
          recordType: 'Order Line Item',
          isChargeAdjustment: false,
          jobType: 'Dumpster',
          product: 'Roll-Off Dumpster',
          size: '20 yd',
          quantity: 1,
          lifecycleRaw: 'Delivery Executed',
          customerStatus: 'delivered',
          deliveryConfirmed: true,
          deliveryConfirmedDate: daysAgo(10),
          deliveryDate: daysAgo(10),
          endDate: daysFromNow(20),
          rentalDays: 30,
          price: '395.00',
          deliveryFee: '70.00',
          tonsIncluded: '3.00',
          pricePerTon: '50.00',
          lineTotal: '465.00',
          lineTax: '40.00',
          lineTotalAfterTax: '505.00',
          amountCharged: '450.00',
          openBalance: '55.00',
          sfModstamp: daysAgo(1),
        },
        {
          id: SEED.oliA1Adj,
          orderId: SEED.orderA1,
          recordType: 'Additional Tonnage',
          isChargeAdjustment: true,
          customerStatus: 'completed', // adjustments are never units; excluded from unit counts
          additionalTons: '1.70',
          pricePerTon: '50.00',
          lineTotal: '85.00',
          lineTax: '10.00',
          lineTotalAfterTax: '95.00',
          notesToCustomer: '1.7 tons over the 3.0 included',
          sfModstamp: daysAgo(1),
        },
        {
          id: SEED.oliA2Unit,
          orderId: SEED.orderA2,
          recordType: 'Order Line Item',
          isChargeAdjustment: false,
          jobType: 'Dumpster',
          product: 'Roll-Off Dumpster',
          size: '30 yd',
          quantity: 1,
          lifecycleRaw: 'Unconfirmed Delivery',
          customerStatus: 'scheduled',
          deliveryConfirmed: false,
          deliveryDate: daysFromNow(3),
          expectedDeliveryTime: 'AM',
          lineTotal: '325.50',
          openBalance: '325.50',
          sfModstamp: daysAgo(1),
        },
        {
          id: SEED.oliB1Unit,
          orderId: SEED.orderB1,
          recordType: 'Order Line Item',
          isChargeAdjustment: false,
          jobType: 'Porta Potty',
          product: 'Standard Porta Potty',
          size: 'Standard',
          quantity: 2,
          service: 'Weekly',
          serviceDays: 'Mon',
          lifecycleRaw: 'Removal Requested',
          customerStatus: 'removal_requested',
          deliveryConfirmed: true,
          deliveryConfirmedDate: daysAgo(30),
          deliveryDate: daysAgo(30),
          projectedEndDate: daysFromNow(5),
          lineTotal: '410.00',
          amountCharged: '410.00',
          openBalance: '0.00',
          sfModstamp: daysAgo(2),
        },
        {
          id: SEED.oliB2Unit,
          orderId: SEED.orderB2,
          recordType: 'Order Line Item',
          isChargeAdjustment: false,
          jobType: 'Dumpster',
          product: 'Roll-Off Dumpster',
          size: '10 yd',
          quantity: 1,
          lifecycleRaw: 'Removal Complete',
          customerStatus: 'completed',
          deliveryConfirmed: true,
          deliveryConfirmedDate: daysAgo(90),
          removalConfirmed: true,
          removalConfirmedDate: daysAgo(60),
          deliveryDate: daysAgo(90),
          endDate: daysAgo(60),
          lineTotal: '298.00',
          amountCharged: '298.00',
          openBalance: '0.00',
          sfModstamp: daysAgo(60),
        },
        {
          id: SEED.oliOther,
          orderId: SEED.orderOther,
          recordType: 'Order Line Item',
          isChargeAdjustment: false,
          jobType: 'Dumpster',
          product: 'Roll-Off Dumpster',
          size: '15 yd',
          quantity: 1,
          lifecycleRaw: 'Delivery Executed',
          customerStatus: 'delivered',
          deliveryConfirmed: true,
          deliveryDate: daysAgo(5),
          lineTotal: '75.00',
          sfModstamp: daysAgo(1),
        },
      ],
    })

    // 5) Cases — 1 open, 1 closed, both Acme
    await prisma.caseRecord.createMany({
      data: [
        {
          id: SEED.caseOpen,
          accountId: SEED.acmeAccountId,
          caseNumber: '00001001',
          subject: 'Swap needed at Acme Store #101',
          description: 'Dumpster is full, please swap.',
          type: 'Swap Dumpster',
          statusRaw: 'New',
          status: 'open',
          priority: 'Medium',
          origin: 'Email',
          orderId: SEED.orderA1,
          orderLineItemId: SEED.oliA1Unit,
          sfCreatedAt: daysAgo(2),
          sfModstamp: daysAgo(1),
        },
        {
          id: SEED.caseClosed,
          accountId: SEED.acmeAccountId,
          caseNumber: '00001002',
          subject: 'Delivery ETA for the 30 yd',
          type: 'Delivery ETA',
          statusRaw: 'Closed',
          status: 'closed',
          resolution: 'Confirmed AM delivery window with the customer.',
          origin: 'Phone',
          orderId: SEED.orderA2,
          sfCreatedAt: daysAgo(6),
          closedAt: daysAgo(5),
          sfModstamp: daysAgo(5),
        },
      ],
    })

    // 6) Transactions — 2 payments + 1 void (void must never render or count)
    await prisma.transaction.createMany({
      data: [
        {
          id: SEED.txnPayment1,
          name: 'T-000001',
          type: 'Payments',
          amount: '100.00',
          preTaxAmount: '92.00',
          tax: '8.00',
          cardLast4: '4242',
          cardType: 'Visa',
          itemDescription: 'Payment on ORD-1001',
          orderId: SEED.orderA1,
          accountId: SEED.acmeAccountId,
          sfCreatedAt: daysAgo(8),
          sfModstamp: daysAgo(8),
        },
        {
          id: SEED.txnPayment2,
          name: 'T-000002',
          type: 'Payments',
          amount: '24.50',
          preTaxAmount: '24.50',
          tax: '0.00',
          cardLast4: '4242',
          cardType: 'Visa',
          itemDescription: 'Payment on ORD-1003',
          orderId: SEED.orderB1,
          accountId: SEED.acmeAccountId,
          sfCreatedAt: daysAgo(4),
          sfModstamp: daysAgo(4),
        },
        {
          id: SEED.txnVoid,
          name: 'T-000003',
          type: 'Void',
          isVoided: true,
          countsTowardTotals: false,
          amount: '50.00',
          cardLast4: '4242',
          cardType: 'Visa',
          itemDescription: 'Voided duplicate charge',
          orderId: SEED.orderA1,
          accountId: SEED.acmeAccountId,
          sfCreatedAt: daysAgo(3),
          sfModstamp: daysAgo(3),
        },
      ],
    })

    // 7) Invoices — 1 open (475.50 due: equals the sum of open-order balances
    //    150.00 + 325.50, so the dashboard openBalance is '475.50' under either
    //    derivation), 1 paid
    await prisma.invoice.createMany({
      data: [
        {
          id: SEED.invoiceOpen,
          name: 'INV-2001',
          amountTotal: '600.00',
          amountPaid: '124.50',
          amountDue: '475.50',
          taxAmount: '50.00',
          transactionDate: daysAgo(9),
          dueDate: daysFromNow(21),
          status: 'Open',
          type: 'Invoice',
          paymentMethod: 'Net Terms',
          memo: 'Net 30 — ORD-1001',
          orderId: SEED.orderA1,
          accountId: SEED.acmeAccountId,
          sfModstamp: daysAgo(1),
        },
        {
          id: SEED.invoicePaid,
          name: 'INV-2002',
          amountTotal: '298.00',
          amountPaid: '298.00',
          amountDue: '0.00',
          taxAmount: '0.00',
          transactionDate: daysAgo(80),
          dueDate: daysAgo(50),
          status: 'Paid In Full',
          type: 'Invoice',
          paymentMethod: 'Net Terms',
          orderId: SEED.orderB2,
          accountId: SEED.acmeAccountId,
          sfModstamp: daysAgo(50),
        },
      ],
    })

    // 8) Delivery confirmation, saved card
    await prisma.deliveryConfirmation.create({
      data: {
        id: SEED.deliveryConf,
        orderId: SEED.orderA1,
        orderLineItemId: SEED.oliA1Unit,
        isResponded: true,
        respondedOn: daysAgo(10),
        response: 'yes',
        sfModstamp: daysAgo(10),
      },
    })
    await prisma.savedCard.create({
      data: {
        id: SEED.savedCard,
        accountId: SEED.acmeAccountId,
        company: 'LDR Site Services',
        label: 'Corporate Visa',
        last4: '4242',
        cardType: 'Visa',
        expiryDisplay: '12/2028',
        isPrimary: true,
        sfModstamp: daysAgo(1),
      },
    })

    // 9) A successful sync run finished "now" — keeps the staleness banner quiet
    await prisma.syncRun.create({
      data: {
        status: 'success',
        startedAt: new Date(now.getTime() - 60_000),
        finishedAt: now,
        objectsSummary: { seeded: true },
      },
    })
  }

  // CLI entry: `tsx scripts/seed-dev.ts` (argv[1] is this file). When imported
  // by vitest/playwright, argv[1] is the runner binary and this block is skipped.
  if (process.argv[1] !== undefined && /seed-dev/.test(process.argv[1])) {
    seed()
      .then(async () => {
        const prisma = await getDb()
        await prisma.$disconnect()
        console.log('Seed complete: 2 accounts, 2 contacts, 5 orders, 6 line items, 2 invoices, 3 transactions, 2 cases.')
      })
      .catch(async (err: unknown) => {
        console.error(err)
        const prisma = await getDb()
        await prisma.$disconnect()
        process.exitCode = 1
      })
  }
  ```

- [ ] Add the two npm scripts. In `package.json`, inside `"scripts"`, add:

  ```json
  "db:seed": "tsx scripts/seed-dev.ts",
  "e2e": "playwright test"
  ```

- [ ] Run the integration test again — expected PASS (6 tests):

  ```bash
  npx vitest run tests/integration/seed.test.ts
  ```

  Also verify the CLI path works and is idempotent by running it twice:

  ```bash
  npm run db:seed && npm run db:seed
  ```

  Expected: both runs print `Seed complete: ...` and exit 0.

- [ ] Create `playwright.config.ts` (complete file):

  ```ts
  import { defineConfig, devices } from '@playwright/test'

  export default defineConfig({
    testDir: 'tests/e2e',
    fullyParallel: false,
    workers: 1, // tests share one seeded database and one dev server
    timeout: 60_000,
    expect: { timeout: 10_000 }, // first hits compile pages in `next dev`
    reporter: 'list',
    use: {
      baseURL: 'http://localhost:3000',
      trace: 'retain-on-failure',
    },
    projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
    webServer: {
      command: 'npm run dev',
      port: 3000,
      reuseExistingServer: true,
      timeout: 120_000,
      // Merged over process.env by Playwright — pins hermetic modes.
      env: { SF_MODE: 'mock', EMAIL_MODE: 'log' },
    },
  })
  ```

- [ ] Keep vitest away from the Playwright specs. Open `vitest.config.ts`; the `test.include` must cover only `tests/unit` and `tests/integration`, and `tests/e2e` must be excluded. If it is not already in this shape, make it so:

  ```ts
  // vitest.config.ts — target state of the `test` block
  test: {
    include: ['tests/unit/**/*.test.ts', 'tests/integration/**/*.test.ts'],
    exclude: ['tests/e2e/**', 'node_modules/**'],
  },
  ```

- [ ] Add the Playwright report dir to `.gitignore` (append this line — `test-results/` is already ignored by Task 1's `.gitignore`, do not add it again):

  ```
  playwright-report/
  ```

- [ ] Create `tests/e2e/portal.spec.ts` (complete file):

  ```ts
  // tests/e2e/portal.spec.ts
  //
  // Full portal walk against the fixture set from scripts/seed-dev.ts.
  // The dev server is started by playwright.config.ts (npm run dev, :3000).
  //
  // Login bypasses email entirely: we insert a MagicLink row directly (same
  // sha256-hex-at-rest scheme as lib/auth/magicLink.ts), then hit
  // /auth/verify?token=<raw> exactly as a customer clicking the emailed link.
  import { execSync } from 'node:child_process'
  import { createHash, randomBytes } from 'node:crypto'
  import { expect, test, type Page } from '@playwright/test'
  import { PrismaClient } from '@prisma/client'

  // Fresh client — Playwright runs outside Next's module graph, so we do not
  // touch the lib/db singleton (which exists for the app's hot-reload guard).
  const prisma = new PrismaClient()

  const ACME_ORDER_WITH_ADJUSTMENT = 'a00SEEDACME1000001' // ORD-1001
  const OTHER_CO_ORDER = 'a00SEEDOTHER000001' // ORD-9001 — belongs to "Other Co"

  async function loginAs(page: Page, email: string): Promise<void> {
    const raw = `e2e-token-${Date.now()}-${randomBytes(4).toString('hex')}`
    await prisma.magicLink.create({
      data: {
        email,
        tokenHash: createHash('sha256').update(raw).digest('hex'),
        expiresAt: new Date(Date.now() + 15 * 60 * 1000),
      },
    })
    await page.goto(`/auth/verify?token=${raw}`)
    // demo@acme.test maps to exactly one account → straight to the dashboard
    await page.waitForURL('**/dashboard')
  }

  test.beforeAll(() => {
    execSync('npx tsx scripts/seed-dev.ts', { stdio: 'inherit' })
  })

  test.afterAll(async () => {
    await prisma.$disconnect()
  })

  test('magic-link login lands on the dashboard with seeded stats', async ({ page }) => {
    await loginAs(page, 'demo@acme.test')
    await expect(page).toHaveURL(/\/dashboard/)
    // 3 active units: delivered (ORD-1001) + scheduled (ORD-1002) + removal_requested (ORD-1003);
    // the completed unit (ORD-1004) and the Additional Tonnage adjustment do not count.
    await expect(page.getByTestId('stat-active-units')).toHaveText('3')
    await expect(page.getByTestId('stat-active-sites')).toHaveText('2')
    await expect(page.getByTestId('stat-open-balance')).toContainText('475.50')
    // Seed wrote a success SyncRun finished "now" — the DELAYED banner must be
    // absent. (Do not assert on /data as of/: the layout's footer legitimately
    // renders "Data as of {time}" when data is fresh.)
    await expect(page.getByText(/sync delayed/i)).toHaveCount(0)
  })

  test('sites page shows both site groups with all Acme order numbers', async ({ page }) => {
    await loginAs(page, 'demo@acme.test')
    await page.goto('/sites')
    await expect(page.getByTestId('site-group')).toHaveCount(2)
    for (const orderNumber of ['ORD-1001', 'ORD-1002', 'ORD-1003', 'ORD-1004']) {
      await expect(page.getByText(orderNumber)).toBeVisible()
    }
    // The foil account's order must never leak into Acme's view.
    await expect(page.getByText('ORD-9001')).toHaveCount(0)
  })

  test('order detail shows the unit and its adjustment without a phantom second unit', async ({ page }) => {
    await loginAs(page, 'demo@acme.test')
    await page.goto(`/orders/${ACME_ORDER_WITH_ADJUSTMENT}`)
    await expect(page.getByText('ORD-1001')).toBeVisible()
    await expect(page.getByTestId('unit-card')).toHaveCount(1) // adjustment must NOT render as a unit
    await expect(page.getByText('20 yd')).toBeVisible()
    await expect(page.getByText(/additional charges/i)).toBeVisible()
    await expect(page.getByText('Additional Tonnage')).toBeVisible()
    await expect(page.getByText(/85\.00/)).toBeVisible()
  })

  test('billing shows the open invoice and payments but never the void', async ({ page }) => {
    await loginAs(page, 'demo@acme.test')
    await page.goto('/billing')
    await expect(page.getByText('INV-2001')).toBeVisible()
    await expect(page.getByText('T-000001')).toBeVisible()
    await expect(page.getByText('T-000002')).toBeVisible()
    await expect(page.getByText('T-000003')).toHaveCount(0) // voided — excluded
    await expect(page.getByText('Corporate Visa')).toBeVisible()
  })

  test('support lists the seeded cases', async ({ page }) => {
    await loginAs(page, 'demo@acme.test')
    await page.goto('/support')
    await expect(page.getByText('00001001')).toBeVisible()
    await expect(page.getByText('00001002')).toBeVisible()
  })

  test('customer can submit a service request and see it tracked as Submitted', async ({ page }) => {
    await loginAs(page, 'demo@acme.test')
    await page.goto('/support/new')
    await page.getByLabel(/^type/i).selectOption({ label: 'Service Issue' })
    await page.getByLabel(/^description/i).fill('E2E: gate blocked, cannot access unit')
    await page.getByRole('button', { name: /submit/i }).click()
    await page.waitForURL('**/support**')
    await expect(page.getByText(/your requests/i)).toBeVisible()
    await expect(page.getByText('E2E: gate blocked, cannot access unit')).toBeVisible()
    await expect(page.getByText(/submitted/i).first()).toBeVisible()
  })

  test("SECURITY: another account's order URL returns 404", async ({ page }) => {
    await loginAs(page, 'demo@acme.test')
    const response = await page.goto(`/orders/${OTHER_CO_ORDER}`)
    expect(response?.status()).toBe(404)
    await expect(page.getByText(/404|not found/i).first()).toBeVisible()
    await expect(page.getByText('ORD-9001')).toHaveCount(0)
  })
  ```

- [ ] Install the Playwright browser binary (one-time; this is a browser download, not an npm dependency):

  ```bash
  npx playwright install chromium
  ```

- [ ] Run the suite and watch the targeted failures:

  ```bash
  npm run e2e
  ```

  Expected: the flow assertions pass, but the `getByTestId(...)` assertions fail with `Error: expect(locator).toHaveText/toHaveCount ... waiting for getByTestId('stat-active-units')` — the pages built in earlier tasks do not yet carry the test hooks. (If anything *else* fails — login redirect, order-detail 404, void transaction visible — that is a real regression in an earlier task; fix it there, do not weaken the spec.)

- [ ] Add the test hooks to the three pages. Task 13 renders the dashboard stats through its local `StatCard` component and order-detail units through `UnitCard` — the hooks are added by giving both components an optional `testId?: string` prop rendered as `data-testid` (React drops the attribute when `testId` is undefined). The diffs below match Task 13's code exactly:

  1. `app/(portal)/dashboard/page.tsx` — add the `testId` prop to `StatCard`. It goes on the **value element**, not the card root: the card root also renders the label, and the e2e pins exact text (`toHaveText('3')` requires no extra text inside the testid element; the balance element may include a `$` prefix — the e2e uses `toContainText` there).

     ```tsx
     -function StatCard({ label, value }: { label: string; value: string }) {
     +function StatCard({ label, value, testId }: { label: string; value: string; testId?: string }) {
        return (
          <div className="rounded-lg border border-slate-200 bg-white px-5 py-4">
            <div className="text-sm text-slate-500">{label}</div>
     -      <div className="mt-1 text-3xl font-semibold tabular-nums tracking-tight">{value}</div>
     +      <div className="mt-1 text-3xl font-semibold tabular-nums tracking-tight" data-testid={testId}>
     +        {value}
     +      </div>
          </div>
        )
      }
     ```

     Call sites:

     ```tsx
     -        <StatCard label="Active units" value={String(data.activeUnits)} />
     -        <StatCard label="Sites with active units" value={String(data.sitesWithActiveUnits)} />
     -        <StatCard label="Open balance" value={formatMoney(data.openBalance) ?? '$0.00'} />
     +        <StatCard label="Active units" value={String(data.activeUnits)} testId="stat-active-units" />
     +        <StatCard label="Sites with active units" value={String(data.sitesWithActiveUnits)} testId="stat-active-sites" />
     +        <StatCard label="Open balance" value={formatMoney(data.openBalance) ?? '$0.00'} testId="stat-open-balance" />
     ```

  2. `app/(portal)/sites/page.tsx` — the `<section>` rendered once per `SiteGroup` (the element wrapping one group's heading + its order rows) gets the attribute directly:

     ```tsx
     -          {sites.map((site) => (
     -            <section key={site.siteKey} className="rounded-lg border border-slate-200 bg-white">
     +          {sites.map((site) => (
     +            <section key={site.siteKey} data-testid="site-group" className="rounded-lg border border-slate-200 bg-white">
     ```

  3. `app/(portal)/orders/[id]/page.tsx` — add the `testId` prop to `UnitCard`, spread on its root `<section>`. It renders once per entry of `OrderDetail.units` and must NOT appear on adjustment renderings (`AdjustmentList` items or the unattached-adjustments `<section>`):

     ```tsx
     -function UnitCard({ unit }: { unit: UnitDetail }) {
     +function UnitCard({ unit, testId }: { unit: UnitDetail; testId?: string }) {
        const title = [unit.jobType, unit.product, unit.size]
          .filter((part): part is string => part !== null && part !== '')
          .join(' — ')
        return (
     -    <section className="rounded-lg border border-slate-200 bg-white px-5 py-4">
     +    <section className="rounded-lg border border-slate-200 bg-white px-5 py-4" data-testid={testId}>
     ```

     Call site:

     ```tsx
     -        {order.units.map((unit) => (
     -          <UnitCard key={unit.id} unit={unit} />
     -        ))}
     +        {order.units.map((unit) => (
     +          <UnitCard key={unit.id} unit={unit} testId="unit-card" />
     +        ))}
     ```

- [ ] Re-run the suite — expected PASS (7 passed):

  ```bash
  npm run e2e
  ```

- [ ] Run the full non-e2e suite to confirm nothing regressed:

  ```bash
  npm test
  ```

- [ ] Commit:

  ```bash
  git add -A
  git commit -m "test: dev seed script and e2e portal suite"
  ```

---

### Task 18: Deployment Artifacts + Runbook + Final README

**Files:**
- Create: `amplify.yml`
- Create: `scripts/build-lambdas.mjs`
- Create: `infra/README.md`
- Modify: `package.json` (add `build:lambdas` script)
- Modify: `.gitignore` (ignore `dist/`)
- Modify: `README.md` (full rewrite)
- Test: none (this task authors build/config/docs files; verification is running `npm run build:lambdas` green + content checks. **No AWS command in `infra/README.md` is executed in this task.**)

**Interfaces:**

Consumes (from earlier tasks):
- `jobs/sync.ts`, `jobs/drain.ts`, `jobs/backfill.ts` — each exports a Lambda `handler` (deployed as `index.handler`) and doubles as a CLI entry; `jobs/backfill.ts` accepts an event of shape `{ "object": "<SyncObject>" }`.
- `prisma/schema.prisma` generator with `binaryTargets = ["native", "rhel-openssl-3.0.x"]` (Task 2) — `npx prisma generate` therefore places a `libquery_engine-rhel-openssl-3.0.x.so.node` engine in `node_modules/.prisma/client`.
- `getSecret(name: string): Promise<string>` (Task 3) — env var first, then AWS Secrets Manager at `${SECRETS_PREFIX}/<name>` (e.g. `streamlink/SF_CLIENT_ID`).
- npm scripts referenced by the README: `db:up`, `db:migrate` (Task 2), `db:seed`, `e2e` (Task 17), `dev`, `test`, `build` (Task 1).

Produces (project-final):
- `npm run build:lambdas` → `dist/lambdas/sync.zip`, `dist/lambdas/drain.zip`, `dist/lambdas/backfill.zip` — the artifacts every Lambda command in `infra/README.md` uploads.
- `amplify.yml` — the buildspec Amplify Hosting picks up when the repo is connected (runbook §7).
- `infra/README.md` — the complete production deploy runbook (§1–§9).
- `README.md` — the repo front door.

**Steps:**

- [ ] Run the (not-yet-existing) bundler to establish the failing baseline:

  ```bash
  npm run build:lambdas
  ```

  Expected failure: `npm error Missing script: "build:lambdas"`.

- [ ] Add the script to `package.json` (inside `"scripts"`):

  ```json
  "build:lambdas": "node scripts/build-lambdas.mjs"
  ```

  And append to `.gitignore`:

  ```
  dist/
  ```

- [ ] Create `scripts/build-lambdas.mjs` (complete file):

  ```js
  // scripts/build-lambdas.mjs
  //
  // Bundles the three job entrypoints into self-contained Lambda packages.
  //
  // Prisma cannot be fully bundled: @prisma/client loads a native query-engine
  // binary at runtime. Standard approach: mark '@prisma/client' external in
  // esbuild, then copy node_modules/@prisma/client + node_modules/.prisma
  // (which holds the generated client and the rhel-openssl-3.0.x engine — see
  // binaryTargets in prisma/schema.prisma) into each Lambda package, pruning
  // the engines for other platforms.
  //
  // Output: dist/lambdas/{sync,drain,backfill}/ and dist/lambdas/<job>.zip
  // Run:    npm run build:lambdas   (requires a prior `npx prisma generate`)

  import { execSync } from 'node:child_process'
  import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
  import path from 'node:path'
  import process from 'node:process'
  import { build } from 'esbuild'

  const JOBS = ['sync', 'drain', 'backfill']
  const ROOT = process.cwd()
  const DIST = path.join(ROOT, 'dist', 'lambdas')
  const LAMBDA_ENGINE = 'rhel-openssl-3.0.x'

  const generatedClient = path.join(ROOT, 'node_modules', '.prisma')
  if (!existsSync(generatedClient)) {
    console.error('node_modules/.prisma not found — run `npx prisma generate` first.')
    process.exit(1)
  }

  rmSync(DIST, { recursive: true, force: true })

  for (const job of JOBS) {
    const outDir = path.join(DIST, job)
    mkdirSync(outDir, { recursive: true })

    await build({
      entryPoints: [path.join(ROOT, 'jobs', `${job}.ts`)],
      outfile: path.join(outDir, 'index.mjs'),
      bundle: true,
      platform: 'node',
      target: 'node20',
      format: 'esm',
      sourcemap: false,
      external: ['@prisma/client'],
      // Bundled CJS dependencies (pino, AWS SDK internals) may call require();
      // provide one in the ESM output.
      banner: {
        js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);",
      },
    })

    // Copy the Prisma client + generated engine next to the handler so the
    // externalized `import '@prisma/client'` resolves inside the zip.
    const nm = path.join(outDir, 'node_modules')
    cpSync(path.join(ROOT, 'node_modules', '@prisma', 'client'), path.join(nm, '@prisma', 'client'), {
      recursive: true,
      dereference: true,
    })
    cpSync(generatedClient, path.join(nm, '.prisma'), { recursive: true, dereference: true })

    // Keep only the Lambda engine binary — native (darwin/debian) engines are
    // tens of MB each and useless on Lambda.
    const clientDir = path.join(nm, '.prisma', 'client')
    for (const file of readdirSync(clientDir)) {
      if (file.startsWith('libquery_engine-') && !file.includes(LAMBDA_ENGINE)) {
        rmSync(path.join(clientDir, file), { force: true })
      }
    }

    execSync(`zip -qr ../${job}.zip .`, { cwd: outDir, stdio: 'inherit' })
    console.log(`built dist/lambdas/${job}.zip`)
  }
  ```

- [ ] Run the bundler — expected PASS with three zips:

  ```bash
  npx prisma generate
  npm run build:lambdas
  ls -l dist/lambdas/*.zip
  ```

  Expected output ends with `built dist/lambdas/sync.zip`, `... drain.zip`, `... backfill.zip`, and `ls` shows exactly three zip files. Sanity-check the packaging of one:

  ```bash
  unzip -l dist/lambdas/sync.zip | grep -E 'index\.mjs|rhel-openssl-3\.0\.x'
  ```

  Expected: one `index.mjs` line and at least one `libquery_engine-rhel-openssl-3.0.x` line; there must be **no** `libquery_engine-darwin` line.

- [ ] Create `amplify.yml` (complete file):

  ```yaml
  # AWS Amplify Hosting buildspec — Next.js SSR (platform WEB_COMPUTE).
  # Secrets are NOT injected here: the app fetches them at runtime through
  # getSecret() / Secrets Manager (see infra/README.md §2 and §7). Only
  # non-secret env vars are configured on the Amplify app.
  version: 1
  backend:
    phases:
      build:
        commands:
          # The sync/drain/backfill Lambdas are NOT deployed by Amplify.
          # They are bundled by `npm run build:lambdas` and deployed with the
          # AWS CLI — see infra/README.md §4.
          - echo "no Amplify-managed backend"
  frontend:
    phases:
      preBuild:
        commands:
          - npm ci
          - npx prisma generate
      build:
        commands:
          - npm run build
          # Amplify's Next.js SSR runtime packages .next plus node_modules;
          # prune dev-only packages per the Amplify Next SSR defaults so the
          # compute bundle ships production deps only.
          - npm prune --omit=dev
    artifacts:
      baseDirectory: .next
      files:
        - '**/*'
    cache:
      paths:
        - node_modules/**/*
        - .next/cache/**/*
  ```

- [ ] Create `infra/README.md` (complete file):

  ````markdown
  # Streamlink v2 — Production Deploy Runbook

  Every command below is run by an operator with admin AWS credentials.
  Placeholders are marked `<LIKE_THIS>` — replace all of them; nothing here is
  executed by CI. Region is assumed consistent throughout (`<AWS_REGION>`,
  e.g. `us-east-1`). `<AWS_ACCOUNT_ID>` is the 12-digit account id.

  Prerequisites: AWS CLI v2 configured, `npm ci && npx prisma generate` done
  locally, and repo admin access for the Amplify connection (§7).

  ---

  ## 1) RDS — portal Postgres (private)

  ```bash
  # Security groups: one for the job Lambdas, one for RDS; RDS accepts 5432
  # only from the Lambda SG.
  aws ec2 create-security-group --group-name streamlink-lambda-sg \
    --description "Streamlink job lambdas" --vpc-id <VPC_ID>
  aws ec2 create-security-group --group-name streamlink-rds-sg \
    --description "Streamlink portal RDS" --vpc-id <VPC_ID>
  aws ec2 authorize-security-group-ingress --group-id <RDS_SG_ID> \
    --protocol tcp --port 5432 --source-group <LAMBDA_SG_ID>

  aws rds create-db-subnet-group \
    --db-subnet-group-name streamlink-db-subnets \
    --db-subnet-group-description "Streamlink private subnets" \
    --subnet-ids <PRIVATE_SUBNET_ID_1> <PRIVATE_SUBNET_ID_2>

  aws rds create-db-instance \
    --db-instance-identifier streamlink-db \
    --engine postgres --engine-version 16.4 \
    --db-instance-class db.t4g.micro \
    --allocated-storage 20 --storage-type gp3 \
    --db-name streamlink \
    --master-username streamlink_admin \
    --master-user-password '<DB_MASTER_PASSWORD>' \
    --db-subnet-group-name streamlink-db-subnets \
    --vpc-security-group-ids <RDS_SG_ID> \
    --no-publicly-accessible \
    --storage-encrypted \
    --backup-retention-period 7

  aws rds wait db-instance-available --db-instance-identifier streamlink-db
  aws rds describe-db-instances --db-instance-identifier streamlink-db \
    --query 'DBInstances[0].Endpoint.Address' --output text   # → <RDS_ENDPOINT>
  ```

  > ⚠️ **Amplify reachability:** Amplify Hosting's SSR compute does not run
  > inside your VPC. With the instance private, the web app cannot reach it
  > until you either (a) attach an Amplify VPC connector / compute networking
  > if enabled for your account, or (b) temporarily allow public access with a
  > tight SG (`aws rds modify-db-instance --db-instance-identifier
  > streamlink-db --publicly-accessible` plus an ingress rule limited to the
  > required CIDRs). Decide before first deploy; Lambdas always connect
  > privately in-VPC either way. Migrations (§7) need the same reachability
  > from the operator machine — a bastion or a temporary public toggle.

  ## 2) Secrets Manager

  ```bash
  aws secretsmanager create-secret --name streamlink/SF_CLIENT_ID \
    --secret-string '<SF_CLIENT_ID>'
  aws secretsmanager create-secret --name streamlink/SF_CLIENT_SECRET \
    --secret-string '<SF_CLIENT_SECRET>'
  aws secretsmanager create-secret --name streamlink/DATABASE_URL \
    --secret-string 'postgresql://streamlink_admin:<DB_MASTER_PASSWORD>@<RDS_ENDPOINT>:5432/streamlink?schema=public&connection_limit=5'
  ```

  Names must be `<SECRETS_PREFIX>/<NAME>` — the app's `getSecret()` resolves
  env vars first, then `${SECRETS_PREFIX}/<NAME>` in Secrets Manager. All
  environments below set `SECRETS_PREFIX=streamlink`.

  ## 3) Salesforce — Connected App + integration user (admin action, in SF)

  Per spec §3 (`docs/superpowers/specs/2026-07-16-streamlink-v2-design.md`)
  and the security findings in `docs/design-input-brief.md` §6:

  1. **Connected App** (Setup → App Manager → New Connected App): enable
     OAuth; enable the **Client Credentials** flow; set "Run As" to the
     integration user below; scope `api`. Copy the Consumer Key/Secret into
     the §2 secrets. This is a NEW app — do not reuse the checkout site's.
  2. **Integration user**: dedicated API-only user (e.g.
     `streamlink.integration@ldr.us`), minimal profile.
  3. **Permission set** `Streamlink Portal Sync` (least privilege):
     - object **Read** only on the nine synced objects: `Account`, `Contact`,
       `Customer_Orders__c`, `Order_Line_Item__c`, `Case`, `Transaction__c`,
       `Netsuite_Transactions__c`, `Vendor_Delivery_Notification__c`,
       `Credit_Card__c`;
     - object **Create** on `Case` only (service-request write-back);
     - FLS: grant read only on the fields in `lib/sync/fieldAllowlist.ts`;
     - **ZERO FLS** — explicitly no read — on:
       `Credit_Card__c.Security_Code__c`, `Credit_Card__c.Credit_Card_Number__c`,
       every `*_Token__c` field, `Authorize_net_Profile_Id__c`,
       `Transaction__c.Payscape_Payload__c` (also keep
       `Transaction__c.Expiration_Date__c` and
       `Transaction__c.Authorization_Code__c` unreadable, per spec §3).
  4. Record `<SF_INSTANCE_URL>` (the org's My Domain URL, e.g.
     `https://ldr.my.salesforce.com`).

  ## 4) Lambdas — sync / drain / backfill

  ```bash
  # Execution role: VPC ENI management + read-only on the streamlink/* secrets.
  aws iam create-role --role-name streamlink-jobs-role \
    --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
  aws iam attach-role-policy --role-name streamlink-jobs-role \
    --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
  aws iam put-role-policy --role-name streamlink-jobs-role \
    --policy-name streamlink-secrets-read \
    --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"secretsmanager:GetSecretValue","Resource":"arn:aws:secretsmanager:<AWS_REGION>:<AWS_ACCOUNT_ID>:secret:streamlink/*"}]}'

  # Build the artifacts (repo root):
  npm run build:lambdas

  # One create-function per job — only name, zip, and timeout differ.
  # sync: must finish inside its 2-minute schedule.
  aws lambda create-function \
    --function-name streamlink-sync \
    --runtime nodejs20.x \
    --handler index.handler \
    --zip-file fileb://dist/lambdas/sync.zip \
    --role arn:aws:iam::<AWS_ACCOUNT_ID>:role/streamlink-jobs-role \
    --timeout 110 --memory-size 512 \
    --vpc-config "SubnetIds=<PRIVATE_SUBNET_ID_1>,<PRIVATE_SUBNET_ID_2>,SecurityGroupIds=<LAMBDA_SG_ID>" \
    --environment "Variables={SECRETS_PREFIX=streamlink,SF_MODE=real,SF_INSTANCE_URL=<SF_INSTANCE_URL>}"

  aws lambda create-function \
    --function-name streamlink-drain \
    --runtime nodejs20.x \
    --handler index.handler \
    --zip-file fileb://dist/lambdas/drain.zip \
    --role arn:aws:iam::<AWS_ACCOUNT_ID>:role/streamlink-jobs-role \
    --timeout 300 --memory-size 512 \
    --vpc-config "SubnetIds=<PRIVATE_SUBNET_ID_1>,<PRIVATE_SUBNET_ID_2>,SecurityGroupIds=<LAMBDA_SG_ID>" \
    --environment "Variables={SECRETS_PREFIX=streamlink,SF_MODE=real,SF_INSTANCE_URL=<SF_INSTANCE_URL>}"

  aws lambda create-function \
    --function-name streamlink-backfill \
    --runtime nodejs20.x \
    --handler index.handler \
    --zip-file fileb://dist/lambdas/backfill.zip \
    --role arn:aws:iam::<AWS_ACCOUNT_ID>:role/streamlink-jobs-role \
    --timeout 900 --memory-size 1024 \
    --vpc-config "SubnetIds=<PRIVATE_SUBNET_ID_1>,<PRIVATE_SUBNET_ID_2>,SecurityGroupIds=<LAMBDA_SG_ID>" \
    --environment "Variables={SECRETS_PREFIX=streamlink,SF_MODE=real,SF_INSTANCE_URL=<SF_INSTANCE_URL>}"
  ```

  > NOTE: Lambdas in private subnets need a **Secrets Manager VPC endpoint**
  > (or NAT) to fetch secrets, and NAT/egress to reach Salesforce:
  > ```bash
  > aws ec2 create-vpc-endpoint --vpc-id <VPC_ID> \
  >   --vpc-endpoint-type Interface \
  >   --service-name com.amazonaws.<AWS_REGION>.secretsmanager \
  >   --subnet-ids <PRIVATE_SUBNET_ID_1> <PRIVATE_SUBNET_ID_2> \
  >   --security-group-ids <LAMBDA_SG_ID>
  > ```
  > Salesforce is on the public internet — the private subnets' route table
  > must have a NAT gateway (`<NAT_GATEWAY_ID>`) or sync cannot call SF.

  Redeploying code later:

  ```bash
  npm run build:lambdas
  aws lambda update-function-code --function-name streamlink-sync    --zip-file fileb://dist/lambdas/sync.zip
  aws lambda update-function-code --function-name streamlink-drain   --zip-file fileb://dist/lambdas/drain.zip
  aws lambda update-function-code --function-name streamlink-backfill --zip-file fileb://dist/lambdas/backfill.zip
  ```

  ## 5) EventBridge Scheduler

  ```bash
  aws iam create-role --role-name streamlink-scheduler-role \
    --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"scheduler.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
  aws iam put-role-policy --role-name streamlink-scheduler-role \
    --policy-name invoke-streamlink-jobs \
    --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"lambda:InvokeFunction","Resource":["arn:aws:lambda:<AWS_REGION>:<AWS_ACCOUNT_ID>:function:streamlink-sync","arn:aws:lambda:<AWS_REGION>:<AWS_ACCOUNT_ID>:function:streamlink-drain"]}]}'

  aws scheduler create-schedule --name streamlink-sync-2min \
    --schedule-expression 'rate(2 minutes)' \
    --flexible-time-window Mode=OFF \
    --target '{"Arn":"arn:aws:lambda:<AWS_REGION>:<AWS_ACCOUNT_ID>:function:streamlink-sync","RoleArn":"arn:aws:iam::<AWS_ACCOUNT_ID>:role/streamlink-scheduler-role"}'

  aws scheduler create-schedule --name streamlink-drain-5min \
    --schedule-expression 'rate(5 minutes)' \
    --flexible-time-window Mode=OFF \
    --target '{"Arn":"arn:aws:lambda:<AWS_REGION>:<AWS_ACCOUNT_ID>:function:streamlink-drain","RoleArn":"arn:aws:iam::<AWS_ACCOUNT_ID>:role/streamlink-scheduler-role"}'
  ```

  Overlap safety: the sync itself takes the pg advisory lock
  (`hashtext('streamlink_sync')`) and returns `skipped_locked`, so an
  occasional slow run under a 2-minute rate is harmless.

  ## 6) SES — magic-link mail

  ```bash
  aws sesv2 create-email-identity --email-identity ldrsiteservices.com
  aws sesv2 get-email-identity --email-identity ldrsiteservices.com \
    --query 'DkimAttributes.Tokens'
  # For each of the three tokens returned, add a DNS CNAME:
  #   <TOKEN>._domainkey.ldrsiteservices.com → <TOKEN>.dkim.amazonses.com
  aws sesv2 get-email-identity --email-identity ldrsiteservices.com \
    --query 'VerifiedForSendingStatus'   # wait for true after DNS propagates
  ```

  > **Production access:** new SES accounts are sandboxed (can only send to
  > verified addresses). Request production access in the SES console
  > (Account dashboard → "Request production access"; use case: transactional
  > login links, ~hundreds/day) BEFORE the pilot; approval typically takes
  > ~24h. Grant the Amplify compute role (§7) `ses:SendEmail` on
  > `identity/ldrsiteservices.com`.

  ## 7) Amplify Hosting — the portal app

  ```bash
  aws amplify create-app --name streamlink \
    --repository '<GIT_REPO_HTTPS_URL>' \
    --platform WEB_COMPUTE \
    --access-token '<GIT_PROVIDER_PAT>'

  aws amplify create-branch --app-id <AMPLIFY_APP_ID> \
    --branch-name main --stage PRODUCTION \
    --framework 'Next.js - SSR' --enable-auto-build

  # NON-SECRET env vars only. DATABASE_URL / SF_CLIENT_ID / SF_CLIENT_SECRET
  # stay in Secrets Manager and are fetched at runtime via getSecret().
  aws amplify update-app --app-id <AMPLIFY_APP_ID> \
    --environment-variables SF_MODE=real,EMAIL_MODE=ses,APP_BASE_URL=https://streamlink.ldrsiteservices.com,SECRETS_PREFIX=streamlink,SF_INSTANCE_URL=<SF_INSTANCE_URL>

  # Runtime IAM role so SSR compute can read secrets and send SES mail:
  aws iam create-role --role-name streamlink-amplify-compute \
    --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"amplify.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
  aws iam put-role-policy --role-name streamlink-amplify-compute \
    --policy-name streamlink-runtime \
    --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"secretsmanager:GetSecretValue","Resource":"arn:aws:secretsmanager:<AWS_REGION>:<AWS_ACCOUNT_ID>:secret:streamlink/*"},{"Effect":"Allow","Action":["ses:SendEmail","ses:SendRawEmail"],"Resource":"*"}]}'
  aws amplify update-app --app-id <AMPLIFY_APP_ID> \
    --compute-role-arn arn:aws:iam::<AWS_ACCOUNT_ID>:role/streamlink-amplify-compute

  # Custom domain:
  aws amplify create-domain-association --app-id <AMPLIFY_APP_ID> \
    --domain-name ldrsiteservices.com \
    --sub-domain-settings prefix=streamlink,branchName=main
  aws amplify get-domain-association --app-id <AMPLIFY_APP_ID> \
    --domain-name ldrsiteservices.com
  # → copy the certificate-validation record and the
  #   streamlink.ldrsiteservices.com CNAME target into DNS.
  ```

  **Before the first deploy**, apply migrations to RDS from a machine that can
  reach it (bastion, or the §1 temporary public toggle):

  ```bash
  DATABASE_URL='postgresql://streamlink_admin:<DB_MASTER_PASSWORD>@<RDS_ENDPOINT>:5432/streamlink' \
    npx prisma migrate deploy
  ```

  Then push to `main` — Amplify builds with `amplify.yml` and deploys.

  ## 8) Backfill runbook (one-time, after §4 + migrations)

  Invoke `streamlink-backfill` once per object. **FK parents must precede
  children** (Account before Contact/Orders; Orders before OLIs); among the
  rest, go smallest-first so problems surface cheaply:

  | # | object | approx rows |
  |---|---|---|
  | 1 | `Account` | ~135k (portal record types) |
  | 2 | `Contact` | ~159k |
  | 3 | `Case` | low thousands (filtered slice) |
  | 4 | `Vendor_Delivery_Notification__c` | ~55k |
  | 5 | `Credit_Card__c` | ~94k |
  | 6 | `Netsuite_Transactions__c` | ~94k |
  | 7 | `Customer_Orders__c` | ~250k |
  | 8 | `Transaction__c` | ~562k |
  | 9 | `Order_Line_Item__c` | ~1.0M |

  ```bash
  aws lambda invoke --function-name streamlink-backfill \
    --cli-binary-format raw-in-base64-out \
    --cli-read-timeout 0 \
    --payload '{"object":"Account"}' \
    /tmp/backfill-out.json && cat /tmp/backfill-out.json
  # repeat with {"object":"Contact"}, {"object":"Case"}, ... per the table
  ```

  The backfill pages by `ORDER BY SystemModstamp` with a persisted cursor in
  `sync_state` — if a run times out at 900s, **re-invoke with the same
  payload**; it resumes where it stopped. Watch progress live:

  ```bash
  aws logs tail /aws/lambda/streamlink-backfill --follow
  ```

  Spot-check row counts in the portal DB against the table above before
  enabling the §5 schedules (or after — incremental sync is idempotent).

  ## 9) CloudWatch alarms

  ```bash
  aws sns create-topic --name streamlink-alarms          # → <ALARM_TOPIC_ARN>
  aws sns subscribe --topic-arn <ALARM_TOPIC_ARN> \
    --protocol email --notification-endpoint <OPS_EMAIL>

  # Alarm 1: sync Lambda hard errors — >= 3 in 15 minutes.
  aws cloudwatch put-metric-alarm \
    --alarm-name streamlink-sync-lambda-errors \
    --namespace AWS/Lambda --metric-name Errors \
    --dimensions Name=FunctionName,Value=streamlink-sync \
    --statistic Sum --period 900 --evaluation-periods 1 \
    --threshold 3 --comparison-operator GreaterThanOrEqualToThreshold \
    --treat-missing-data notBreaching \
    --alarm-actions <ALARM_TOPIC_ARN>

  # Alarm 2: 'sync failed' log lines (runSync logs this on failed runs even
  # when the handler exits cleanly).
  aws logs put-metric-filter \
    --log-group-name /aws/lambda/streamlink-sync \
    --filter-name streamlink-sync-failed \
    --filter-pattern '"sync failed"' \
    --metric-transformations metricName=SyncFailedLines,metricNamespace=Streamlink,metricValue=1,defaultValue=0

  aws cloudwatch put-metric-alarm \
    --alarm-name streamlink-sync-failed-loglines \
    --namespace Streamlink --metric-name SyncFailedLines \
    --statistic Sum --period 900 --evaluation-periods 1 \
    --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold \
    --treat-missing-data notBreaching \
    --alarm-actions <ALARM_TOPIC_ARN>

  # Alarm 3: sync LAG — absence of success (spec §8). Errors alone miss a
  # silently stalled pipeline (disabled schedule, hung Lambda, dead network
  # path): the sync job logs one 'sync run success' line per successful run,
  # so alarm when NO such line lands for 3 consecutive 10-minute periods.
  # Missing data = no log traffic at all = breaching, by design.
  aws logs put-metric-filter \
    --log-group-name /aws/lambda/streamlink-sync \
    --filter-name streamlink-sync-success \
    --filter-pattern '"sync run success"' \
    --metric-transformations metricName=SyncSuccess,metricNamespace=Streamlink,metricValue=1,defaultValue=0

  aws cloudwatch put-metric-alarm \
    --alarm-name streamlink-sync-lag \
    --namespace Streamlink --metric-name SyncSuccess \
    --statistic Sum --period 600 --evaluation-periods 3 \
    --threshold 1 --comparison-operator LessThanThreshold \
    --treat-missing-data breaching \
    --alarm-actions <ALARM_TOPIC_ARN>
  ```

  Staleness is also user-visible by design: the portal banners when the last
  successful sync is older than 15 minutes.

  ### Operations queries — dead-lettered service requests

  Service requests that exhaust their 5 push attempts stay in the portal DB
  with `push_stage = 'failed'` (spec §8's internal view). A proper admin UI is
  a deliberate v1 cut (v2 item); until then this runbook query IS the internal
  view — run it with psql against the portal DB (same reachability as the §7
  migration step):

  ```sql
  SELECT id, account_id, type, attempts, last_error, created_at
  FROM service_requests
  WHERE push_stage = 'failed'
  ORDER BY created_at DESC;
  ```
  ````

- [ ] Rewrite the root `README.md` (complete replacement content):

  ````markdown
  # Streamlink v2

  Customer portal for LDR Site Services (`streamlink.ldrsiteservices.com`).
  A Next.js app on AWS with its own Postgres **read model** synced from
  Salesforce every 2 minutes (allowlisted fields only), passwordless
  magic-link auth, account-scoped dashboards (sites/orders, billing, support),
  and one write path: service requests → Salesforce Case.

  - **Spec:** `docs/superpowers/specs/2026-07-16-streamlink-v2-design.md`
  - **Implementation plan:** `docs/superpowers/plans/2026-07-16-streamlink-v2.md`
  - **Salesforce data reference:** `docs/design-input-brief.md`

  ## Architecture

  ```
  SALESFORCE (prod)                        AWS
  ┌──────────────┐   poll Δ (SystemModstamp) ┌──────────────────┐
  │ Account       │   every 2 min             │ Sync worker       │
  │ Contact       │──(allowlisted fields ───▶│ (Lambda, EventBridge│
  │ Customer_Orders__c │  only)               │  Scheduler)       │
  │ Order_Line_Item__c │                      └────────┬─────────┘
  │ Case (slice)  │                                    ▼
  │ Transaction__c│                          ┌──────────────────┐
  │ Netsuite_Transactions__c │               │ Portal Postgres   │
  │ Vendor_Delivery_Notification__c │        │ (RDS, Prisma)     │
  └──────▲───────┘                           └────────┬─────────┘
         │                                            │
         │  staged idempotent write:         ┌────────┴─────────┐
         └── service request → Case ─────────│ Next.js portal    │
             (Origin='Customer Portal')      │ (Amplify SSR)     │
                                             └──────────────────┘
  ```

  Vendor/margin/cost/card-sensitive Salesforce fields never enter this
  system: the sync selects only `lib/sync/fieldAllowlist.ts`, and a unit test
  proves the allowlist never intersects `lib/sync/denylist.ts`.

  ## Local quickstart

  ```bash
  npm ci
  cp .env.example .env      # defaults: SF_MODE=mock, EMAIL_MODE=log
  npm run db:up             # docker Postgres on localhost:5433 (db "streamlink")
  npm run db:migrate        # prisma migrate dev
  npm run db:seed           # idempotent demo dataset
  npm run dev               # http://localhost:3000
  ```

  Log in as **`demo@acme.test`** on `/login` — with `EMAIL_MODE=log` the
  magic link is printed to the dev-server console; open it in the browser.

  | Script | What it does |
  |---|---|
  | `npm run dev` | Next.js dev server on :3000 (`SF_MODE=mock` — no Salesforce network) |
  | `npm run db:up` | Start local Postgres 16 via docker compose (port 5433) |
  | `npm run db:migrate` | Apply Prisma migrations |
  | `npm run db:seed` | Reset + seed deterministic demo data (`scripts/seed-dev.ts`) |
  | `npm test` | vitest — unit (`tests/unit`, no DB) + integration (`tests/integration`, needs `db:up`) |
  | `npm run e2e` | Playwright suite (`tests/e2e`) — seeds the DB itself, boots `npm run dev` |
  | `npm run build:lambdas` | Bundle sync/drain/backfill → `dist/lambdas/*.zip` |

  ## Environment variables

  | Name | Secret? | Purpose |
  |---|---|---|
  | `DATABASE_URL` | **yes** in prod (Secrets Manager `streamlink/DATABASE_URL`); plain `.env` locally | Postgres connection string |
  | `SF_MODE` | no | `mock` (default — hermetic, no network) or `real` |
  | `SF_INSTANCE_URL` | no | Salesforce My Domain base URL (real mode) |
  | `SF_CLIENT_ID` | **yes** (`streamlink/SF_CLIENT_ID`) | Connected App consumer key |
  | `SF_CLIENT_SECRET` | **yes** (`streamlink/SF_CLIENT_SECRET`) | Connected App consumer secret |
  | `EMAIL_MODE` | no | `log` (default — links to console) or `ses` |
  | `APP_BASE_URL` | no | Absolute origin used inside magic-link URLs |
  | `SECRETS_PREFIX` | no | Secrets Manager name prefix (`streamlink`) used by `getSecret()` |

  Secrets are read only through `getSecret()` (`lib/secrets.ts`): env var
  first (local dev), AWS Secrets Manager fallback (prod). They are never
  inlined into the build bundle.

  ## Deploying

  The full production runbook — RDS, Secrets Manager, the Salesforce
  Connected App + least-privilege integration user, the three Lambdas,
  EventBridge schedules, SES, Amplify Hosting, backfill, and CloudWatch
  alarms — lives in [`infra/README.md`](infra/README.md).
  ````

- [ ] Verify the authored content (commands, not AWS calls):

  ```bash
  # three zips still present from the earlier step
  ls dist/lambdas/sync.zip dist/lambdas/drain.zip dist/lambdas/backfill.zip

  # every runbook section exists
  grep -c '^  ## [1-9])' infra/README.md    # expected: 9

  # placeholders are marked, none half-filled
  grep -o '<[A-Z_]\+>' infra/README.md | sort -u

  # README references the local quickstart scripts
  grep -E 'db:up|db:migrate|db:seed|npm run e2e' README.md
  ```

  Expected: `ls` lists all three zips; the section grep returns 9; the
  placeholder list contains entries like `<AWS_ACCOUNT_ID>`, `<AWS_REGION>`,
  `<RDS_ENDPOINT>`, `<DB_MASTER_PASSWORD>`, `<SF_INSTANCE_URL>`,
  `<AMPLIFY_APP_ID>`, `<ALARM_TOPIC_ARN>`; the README grep hits all four
  scripts. (Adjust the section grep to match the file's actual heading
  indentation if it was saved without the code-fence indentation shown above
  — headings must read `## 1) …` through `## 9) …`.)

- [ ] Confirm nothing regressed and the workspace still builds:

  ```bash
  npm test
  npm run build
  ```

  Expected: full vitest suite green; `next build` completes.

- [ ] Commit:

  ```bash
  git add -A
  git commit -m "chore: amplify buildspec, lambda bundler, deploy runbook, and final README"
  ```
