# ZO Number Compliance

How the platform ensures dealers have a ZO (AMSOIL dealer) number, and why the
schema deliberately does **not** enforce it.

> Tracking issue: [#852](https://github.com/aimclear/amsoil-dlp/issues/852)

## Why this exists

A dealer's ZO number (`Dealer.dealerNumber`) is what AMSOIL uses to attribute
sales and pay commissions. A dealer reported (May 2026) that they were earning
no commissions; root cause was a **missing ZO number** on their record. ZO was
optional at every layer - schema, registration, publishing - so a dealer could
sign up, go live, and operate indefinitely with a silently missing ZO, invisible
until they noticed lost income months later. This has direct revenue impact.

The same review flagged dealers operating with **no subdomain** as a related
"looks set up but isn't" failure mode.

## The three mechanisms

### A. At-risk admin report

`GET /api/admin/at-risk` + the `AtRiskPanel` rendered first on `/admin/`.
Surfaces non-cancelled dealers missing a ZO and/or subdomain, oldest-first, so
the team can proactively reach out. This is the **standing monitor** and the
tool used to clean up the legacy backlog (ZO numbers come from AMSOIL and
cannot be auto-derived).

- Route: `app/api/admin/at-risk/route.ts`
- UI: `components/admin/AtRiskPanel.tsx`, wired in `app/admin/page.tsx`

### B. Publish / re-publish gate

A dealer cannot publish **or re-publish** without a ZO number. Already-live
sites are **never** taken down - only the act of (re)publishing is blocked,
which self-enforces cleanup as legacy dealers next edit their site.

Single shared guard: `lib/zo-guard.ts` → `requireZo(dealer)` (returns a `422`
with `code: 'ZO_REQUIRED'`, mirrors the `requireAdmin()` idiom). Called from
every dealer-initiated publish entrypoint:

| Entrypoint | File |
| --- | --- |
| Site publish / first go-live + re-publish | `app/api/dealers/[id]/publish/route.ts` |
| CMS full-site publish | `app/api/cms/publish/route.ts` |
| Publish an individual page | `app/api/cms/pages/[id]/route.ts` (PUT, when `status: 'published'`) |

Saving a **draft** is unaffected. The dealer-facing message is surfaced by
`components/FloatingPublishButton.tsx`.

**Explicitly excluded:** system revalidation / ISR cache refreshes
(`app/api/admin/dealers/[id]/revalidate/route.ts`). These are not dealer-
initiated publishes and must keep working regardless of ZO status. That route
carries a comment forbidding the guard.

### C. Registration intake validation

`validateRegistrationData` (`lib/registration-helpers.ts`) requires a non-empty,
digits-only `dealerNumber`. This closes the server-side gap behind the
client-only check in `OnboardingForm.tsx`; `app/api/register/complete` no longer
silently accepts a blank value.

ZO format validation is intentionally lenient (`isValidZoFormat`): AMSOIL
publishes no ZO spec and real values vary in length, so only obvious data-entry
errors (letters, punctuation) are rejected. A wrong-but-numeric ZO is a
data-correction problem handled by the at-risk report (A), not an intake-format
problem.

## Rejected alternative: making `dealerNumber` `NOT NULL`

Considered and **deliberately rejected** - recorded here so it is not
re-proposed:

1. `NOT NULL` does not forbid `''`, and code already wrote `|| ''`, so it would
   not actually enforce "has a real ZO". Real enforcement is the app layer
   (C) ± a `CHECK` constraint.
2. A `NOT NULL` migration *fails to apply* while any null exists. There is an
   irreducible legacy/cancelled/unreachable tail that may never be fully
   cleaned, so the migration would be permanently blocked - or tempt sentinel
   garbage into a commission-critical field.
3. The business requirement ("can't go live without a ZO" + "a report to
   monitor") is fully met by **A + B + C**. None of it needs the column to be
   non-nullable.
4. `String?` is the honest data model: for some legacy/cancelled dealers the
   value legitimately does not and will never exist. The durable backstop for
   this domain is the **standing A report**, not a constraint that fights
   reality.

## Known limitations / follow-ups

- **Whitespace-only ZO undercounts in the report.** The SQL predicate
  (`OR: [null, '']`) does not match a `dealerNumber` of `'   '`. Each dealer's
  `issues[]` array *does* flag it (JS `isBlank` trims), but `counts.missingZo`
  can undercount. Intake validation (C) trims and rejects whitespace, so this
  only affects pre-existing rows. Tighten the predicate (raw `btrim()`) if
  whitespace-only values prove common.
- **`AtRiskPanel` does not auto-refresh.** Counts load once on mount; after an
  admin fixes a dealer via Inspect, a reload is needed. A re-check button is a
  candidate follow-up.
- Deep-link filter into the shared dealer table; controlling `DealerSearch` so
  Inspect fills the visible box; `not-published` / `SSL-inactive` failure
  modes - all deferred.

## Tests

- `lib/__tests__/zo-guard.test.ts` - guard unit (purity, 422 shape)
- `app/api/dealers/[id]/publish/__tests__/route.test.ts` - blocks first publish
  and re-publish; never mutates the dealer when blocked
- `app/api/cms/pages/__tests__/route.test.ts` - blocks page publish; allows
  draft saves without a ZO
- `lib/__tests__/registration-helpers.test.ts` - required / blank / non-numeric
  / valid ZO at intake
- `app/api/admin/at-risk/__tests__/route.test.ts` +
  `components/__tests__/AtRiskPanel.test.tsx` - the report
- `app/api/cms/publish/__tests__/route.test.ts` - guard short-circuits with
  422 before any render work; passes when a ZO is present
- `components/__tests__/FloatingPublishButton.test.tsx` - a 422 with the ZO
  body surfaces `ZO_REQUIRED_MESSAGE` to the dealer end-to-end

The full `cms/publish` render pipeline past the guard is not exercised (pre-
existing); the guard regression test is the right scope.

## Design note - guard enforces presence, not format

`requireZo` blocks only a missing/blank `dealerNumber`; it does **not** apply
the digits-only `isValidZoFormat` check used at registration intake (C). This
is deliberate, for the same reason the `NOT NULL` migration was rejected: there
is no authoritative AMSOIL ZO format spec, so a hard runtime gate keyed on
format could take an existing dealer's site hostage over legacy data of unknown
shape. Format is enforced for all *new* dealers at intake; malformed legacy
values are surfaced by the at-risk report (A) and corrected by hand. Revisiting
this (e.g. a logged warning rather than a block) is tracked in #855.
