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)
- Migration
00004_advertisers_agencies.sql, forward-only. TDD. Audit every mutation. Bash. - Money/percent rule: agency commission stored as integer basis points (15% = 1500); UI accepts decimal percent ("15" or "15.5"), converts in the handler. No floats in the domain.
Domain decisions (flag to Leo if wrong):
- 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.
- 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). - 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. - Contacts = one free-text field per record (never required). Structured CRM stays phase F.
- 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):
GET /advertisers(list: name, aliases, tags, active) ·GET /advertisers/new·POST /advertisers·GET /advertisers/{id}·POST /advertisers/{id}·POST /advertisers/{id}/delete(confirm dialog; auditdeletewith name in detail)- same shape under
/agencies(list shows commission as percent)
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
go vet ./... && just test && just test-db; compose rebuild.- E2E curl: create agency "True Native Media" 15%; create advertiser "Abine" with alias "DeleteMe", tags "privacy"; RENAME Abine→"Abine, Inc."; delete a throwaway advertiser; audit trail shows create/update/delete with names.
- README row, decisions appended here, commit, republish site. STOP for Leo: create/rename real advertisers, confirm the availability-dummy workaround is dead.
Decisions & deviations (executed 2026-07-06 by Kenobi/Fable 5)
- Per plan throughout; no deviations. E2E data left in dev DB: agency "True Native Media" (15%), advertiser "Abine, Inc." (aliases DeleteMe, Blur; tag privacy) — Leo corrects/extends at verification.
- All green: 7 packages via just test-db; live E2E incl. rename + delete with named audit rows.