TWiT-Ads v2 Rebuild

A4 — Advertisers & Agencies Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: superpowers:executing-plans (or subagent-driven-development). Checkbox steps.

Goal: Advertiser and agency records: editable/deletable names (killing the legacy "can't rename, can't delete" pain and Lisa's availability dummy account), brand aliases (Abine→DeleteMe), competitor tags (warn-only hints later), per-agency commission, optional free-text contacts.

Architecture: Two small stores + CRUD UI, exactly in the idioms of A2/A3 (internal/auth/users.go / internal/shows/shows.go are the exemplars: const cols, scan helper, wrapped errors; handler tests with fakes; audit from handlers). No new dependencies.

Global Constraints (as A1–A3; read ~/Projects/twit-ads/CLAUDE.md first)

Domain decisions (flag to Leo if wrong):

  1. Agency is an order attribute, not an advertiser attribute (an advertiser can come direct once, via agency later). A4 creates both entities independently; B-phase orders link them.
  2. Mutations gated at requireUser (any staff) — Lisa's team creates advertisers mid-proposal; the spec's philosophy is loose gates + human review + audit trail. Deletes too (Lisa asked for delete of never-used advertisers).
  3. Delete guard: v1 checks nothing (no orders exist yet); stage B5 MUST extend delete to refuse when finalized orders reference the record (leave a // TODO(B5) at the delete site). Cascading of pending-only references is also B-phase.
  4. Contacts = one free-text field per record (never required). Structured CRM stays phase F.
  5. Aliases and competitor tags entered comma-separated, stored text[].

Task 1: Migration + advertisers store

Files: app/internal/db/migrations/00004_advertisers_agencies.sql, app/internal/sponsors/sponsors.go (+testhelper_test.go, sponsors_test.go — package sponsors holds both entities; truncate list grows accordingly).

-- +goose Up
CREATE TABLE agencies (
    id             bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name           text NOT NULL UNIQUE,
    commission_bp  int  NOT NULL DEFAULT 1500 CHECK (commission_bp BETWEEN 0 AND 10000),
    contacts       text NOT NULL DEFAULT '',
    active         boolean NOT NULL DEFAULT true,
    created_at     timestamptz NOT NULL DEFAULT now(),
    updated_at     timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE advertisers (
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name            text NOT NULL UNIQUE,
    aliases         text[] NOT NULL DEFAULT '{}',
    competitor_tags text[] NOT NULL DEFAULT '{}',
    contacts        text NOT NULL DEFAULT '',
    active          boolean NOT NULL DEFAULT true,
    created_at      timestamptz NOT NULL DEFAULT now(),
    updated_at      timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE advertisers;
DROP TABLE agencies;

Interfaces:

package sponsors
type Advertiser struct {
    ID int64; Name string; Aliases []string; CompetitorTags []string
    Contacts string; Active bool
}
type Agency struct {
    ID int64; Name string; CommissionBP int; Contacts string; Active bool
}
type Store struct{ Pool *pgxpool.Pool }
// Advertisers: CreateAdvertiser, UpdateAdvertiser (all fields incl. RENAME),
// GetAdvertiser, ListAdvertisers (active first, then name), DeleteAdvertiser.
// Agencies: CreateAgency, UpdateAgency, GetAgency, ListAgencies, DeleteAgency.

Tests: lifecycle incl. rename round-trip; duplicate name rejected; aliases/tags round-trip; delete removes; commission bounds enforced (DB CHECK); list ordering. Red → implement → just test-db green → commit feat(sponsors): advertisers + agencies stores.

Task 2: UI + wiring

Files: app/internal/server/sponsors_ui.go, templates/advertisers.html, templates/advertiser_form.html, templates/agencies.html, templates/agency_form.html; server.go Deps gains Sponsors SponsorStore (interface over the ten methods); home.html nav gains Advertisers + Agencies; main.go wires &sponsors.Store{Pool: pool}; test sponsors_ui_test.go.

Routes (ALL requireUser; every mutation audited with entity advertiser/agency):

Handler details: comma-separated parse helper splitList(s string) []string (trim, drop empties); commission percent→bp: parse decimal string, bp = round(pct*100), reject <0 or >100; form re-render with error on failure (pattern from show_form). Tests: role smoke (sales can create/edit/delete), rename via POST persists, delete audits with name, bad commission (e.g. "101") re-renders 400, aliases comma-string → slice. Red → implement → green → commit feat(sponsors): advertiser/agency UI with rename, aliases, commission.

Task 3: E2E + close out


Decisions & deviations (executed 2026-07-06 by Kenobi/Fable 5)