B2 — Orders List + CRUD Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: superpowers:test-driven-development on every task, plus superpowers:executing-plans (or subagent-driven-development) to run task-by-task. Checkbox steps. Read
~/Projects/twit-ads/CLAUDE.md, the Specification (§3 domain model incl. the "Order modification after signing" binding requirements, §"Order math", §4 workflows W1–W3, §8 B2 bullet),Plans/phase-a-review.md, andPlans/b-phase-handoff.mdfirst — the spec changed 2026-07-07 (Russell's findings), don't trust a cached model.
Goal: The orders header entity — list with status colors/filters/advertiser rollups, create/edit CRUD, duplicate-order, and optimistic-locking concurrency safety (the legacy system's worst bug: concurrent edits silently clobbering each other). No line items yet — episode lines land in B3, catalog/discount/terms-boilerplate lines in B4.
Architecture: New internal/orders package (store, pure Go + pgx, no HTTP) following the exact idioms of internal/sponsors/internal/shows: const column list, scan-row helper, wrapped errors, TRUNCATE-based TEST_DATABASE_URL integration tests. New internal/server/orders_ui.go handlers + templates/orders.html + templates/order_form.html following sponsors_ui.go's exact shape (Deps methods, pageData, form-data wrapper struct, 303-redirect-after-POST, ?err= redirect-with-message on failure). The one genuinely new idiom this stage introduces is optimistic locking (a version column + WHERE id=$1 AND version=$2 + a ErrConflict sentinel, mirroring the rates.ErrNoRate sentinel-error pattern) — nothing in A1–B1 needed this because nothing before orders was concurrently multi-editable in a way that mattered.
Tech Stack: Go 1.25, pgx, stdlib net/http+html/template, goose migration 00006_orders.sql. No htmx yet — the project hasn't introduced it in any prior stage (verified: zero hx-* attributes or vendored JS anywhere in the repo) and B2's plain GET-query-param filters don't need it; the first stage that actually needs live in-page interaction (B3's date picker) is the natural place to introduce it, not here.
Global Constraints
- Money = integer cents, whole-dollar multiples,
internal/money. TDD red/green, one change at a time.go vet ./...+just test(+just test-dbwhere DB touched) green at every commit. - Audit log entry for every order mutation (create/update/duplicate) via
d.Audit.Record(ctx, &actor, "order", id, action, detail). - Migrations forward-only, numbered. Next number is
00006(last is00005_ratecard.sql). - No sentinel values — nullable columns/enums, matching
AgencyID *int64(nil = direct) etc. - No "broadcast" in UI text. All displayed dates MM/DD/YYYY via
mdy;<input type="date">values stay ISO.
Domain decisions (flag to Leo if wrong)
orders.idis the order number. The spec lists "number" as a distinct order field, but no separate numbering scheme (legacy-compatible or otherwise) has been specified, and no other entity in the system has a user-facing code distinct from its identity column. Displaying "Order #42" (the identity column) is the YAGNI choice; revisit if Lisa wants a different scheme (e.g. matching legacy numbers) — that's cheap to add as a display-only column later, or relevant at E0 import time when legacy order numbers arrive vialegacy_id.- B2 creates
ordersonly — noorder_lines/order_line_datestable yet. Those are B3's job ("Order editor: episode lines" is literally B3's title) and B4 extends them with catalog/discount/terms. Building line schema now with no UI to populate it violates YAGNI and this project's "one change at a time" rule. B2's "advertiser rollups" therefore means order counts per advertiser, not dollar totals — there are no line-level dollar amounts to roll up until B3/B4. Duplicate-order in B2 copies header fields only; extending it to copy lines is a one-line addition once B3/B4 introduce lines, not a re-architecture.- Schema note for B3 (per
b-phase-handoff.mdguidance, recorded here so B3 isn't surprised):order_linesneedsepisode_id bigint REFERENCES episodes(id)forepisode_adlines — episode identity, not a bare civil date — so a moved episode carries its sold placements with it (spec §3 item 2).billboard_week/banner_weeklines instead carryweek_start_date date(they aren't tied to a specific show's episode). A per-line-date table (order_line_dates, one row per selected date/week, FK toorder_lines) is the natural shape; aCHECKenforcing exactly one ofepisode_id/week_start_dateper row matches the "no sentinel values" rule.
- Schema note for B3 (per
orders.status,finalized_at,finalized_by,makegood_for_order_id, andversionall exist from day one but B2 only ever producesstatus='pending',finalized_at/by=NULL,makegood_for_order_id=NULL. Adding these columns now is cheap (perb-phase-handoff.md: "cheap now, painful later"); B5 owns the actual finalize/void/makegood transitions as dedicated store methods (mirroringshows.CancelEpisode/ReinstateEpisodebeing separate from the genericUpdate) — B2'sUpdatenever touchesstatus.quarters+yearmodeled asyear int+quarters int[](subset of 1-4, empty = annual/unspecified), mirroringshows.Weekdays's exactint[]+CHECKconvention. Spec: "display metadata; real scheduling is the line dates" — this is intentionally inert beyond display, no business logic reads it.terms_text/presentation/notesare plain editable fields in B2's form, not yet wired to the terms-template substitution engine orordermath.DisplayLines— that automatic boilerplate-generation and discount-driven presentation logic is explicitly B4's job ("terms enum → boilerplate w/ advertiser substitution... presentation toggle" per spec §8). B2 just needs the columns and a plain textarea/select so the header form is complete; B4 upgrades the behavior behind the same fields.salesperson_idis any active user, no role restriction at the DB or Go level. Per spec §2, "role-gating is deliberately loose within staff" and continuity staff can do everything sales can (W3: Debi builds an order). The create form defaultssalesperson_idto the current logged-in user, editable via a dropdown of all users.- All order routes gated
requireUser(any authenticated staff), matchingsponsors_ui.go/admin_shows.go— human review + the audit trail are the control, not route-level role gates, per spec §2's stated philosophy. (Finalize/void in B5 will need a stricter gate — not B2's concern.) - Red "conflict" status badges are NOT implemented in B2. They require placements + finalize (B5's sea-of-red). B2's list shows exactly three status colors: pending (blue), finalized (green), void (grey) — the fourth (
.status-conflict, red) is B5's to add when it has something to color red. No dead CSS added ahead of need.
Task 1: Migration + internal/orders store — Create/Get/List/Duplicate
Files:
- Create:
app/internal/db/migrations/00006_orders.sql - Create:
app/internal/orders/orders.go - Create:
app/internal/orders/orders_test.go - Create:
app/internal/orders/testhelper_test.go
Migration:
-- +goose Up
CREATE TYPE order_status AS ENUM ('pending', 'finalized', 'void');
CREATE TYPE order_presentation AS ENUM ('lumped', 'below_the_line');
CREATE TYPE order_terms AS ENUM ('net_15', 'net_30', 'net_45', 'net_60', 'net_75', 'net_90', 'due_upon_receipt');
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
advertiser_id bigint NOT NULL REFERENCES advertisers(id),
agency_id bigint REFERENCES agencies(id),
salesperson_id bigint NOT NULL REFERENCES users(id),
status order_status NOT NULL DEFAULT 'pending',
year int NOT NULL,
quarters int[] NOT NULL DEFAULT '{}' CHECK (quarters <@ ARRAY[1,2,3,4]),
terms order_terms NOT NULL DEFAULT 'net_45',
terms_text text NOT NULL DEFAULT '',
notes text NOT NULL DEFAULT '',
presentation order_presentation NOT NULL DEFAULT 'lumped',
makegood_for_order_id bigint REFERENCES orders(id),
version int NOT NULL DEFAULT 1,
finalized_at timestamptz,
finalized_by bigint REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX orders_advertiser_idx ON orders (advertiser_id);
CREATE INDEX orders_status_idx ON orders (status);
-- +goose Down
DROP TABLE orders;
DROP TYPE order_terms;
DROP TYPE order_presentation;
DROP TYPE order_status;
Interfaces:
package orders
// Order is the stored header row — distinct from ordermath.Order (B1's pure
// computation input, which carries Lines/Discount/AgencyCommissionBP). B2
// has no lines yet, so nothing here calls ordermath.Compute; that begins
// in B3/B4 once order_lines exists.
type Order struct {
ID int64
Title string
AdvertiserID int64
AgencyID *int64 // nil = direct, no agency
SalespersonID int64
Status string // pending|finalized|void — B2 only ever writes "pending"
Year int
Quarters []int // subset of 1-4; empty = annual/unspecified
Terms string // net_15|net_30|net_45|net_60|net_75|net_90|due_upon_receipt
TermsText string
Notes string
Presentation string // lumped|below_the_line
MakegoodForOrderID *int64 // nil unless a makegood; B5 sets this
Version int // optimistic locking; caller must round-trip the value it loaded
FinalizedAt *time.Time
FinalizedBy *int64
}
type Filter struct {
AdvertiserID int64 // 0 = all
Status string // "" = all
Year int // 0 = all
}
type Store struct{ Pool *pgxpool.Pool }
func (s *Store) Create(ctx context.Context, o Order) (Order, error)
func (s *Store) Get(ctx context.Context, id int64) (Order, error)
func (s *Store) List(ctx context.Context, f Filter) ([]Order, error)
func (s *Store) Duplicate(ctx context.Context, id int64) (Order, error)
- Step 1: Write the failing tests (
orders_test.go), plustesthelper_test.go(copysponsors/testhelper_test.go'stestPoolexactly, TRUNCATE list becomes"TRUNCATE orders, advertisers, agencies, episodes, shows, users, sessions, audit_log RESTART IDENTITY CASCADE", plus aseedFixtureshelper — mirrorsrates/testhelper_test.go'smakeShowpattern for cross-package FK seeding.TestListFiltersbelow calls it twice in one test, andadvertisers.name/users.usernameareUNIQUE, so it needs a counter to avoid collisions — a package-level counter is simplest sincego testruns this package's tests sequentially, not in parallel):
// testhelper_test.go, in addition to testPool (copied from sponsors/testhelper_test.go
// with the TRUNCATE list above):
var seedCounter int64
// seedFixtures creates one advertiser, one agency, and one user, returning
// their IDs — the FK dependencies every Order needs. Each call gets unique
// names (advertisers.name and users.username are both UNIQUE).
func seedFixtures(t *testing.T, pool *pgxpool.Pool) (advertiserID, agencyID, userID int64) {
t.Helper()
seedCounter++
n := seedCounter
ctx := context.Background()
sp := &sponsors.Store{Pool: pool}
adv, err := sp.CreateAdvertiser(ctx, sponsors.Advertiser{Name: fmt.Sprintf("Advertiser %d", n), Active: true})
if err != nil {
t.Fatalf("seed advertiser: %v", err)
}
ag, err := sp.CreateAgency(ctx, sponsors.Agency{Name: fmt.Sprintf("Agency %d", n), CommissionBP: 1500, Active: true})
if err != nil {
t.Fatalf("seed agency: %v", err)
}
us := &auth.Users{Pool: pool}
u, err := us.Create(ctx, fmt.Sprintf("user%d", n), fmt.Sprintf("User %d", n), "testpassword123", auth.RoleSales, false)
if err != nil {
t.Fatalf("seed user: %v", err)
}
return adv.ID, ag.ID, u.ID
}
(testhelper_test.go needs "fmt", "twit.tv/twitads/internal/auth", and "twit.tv/twitads/internal/sponsors" added to its imports beyond the base testPool copy.)
package orders
import (
"context"
"slices"
"testing"
)
func TestCreateGetRoundTrip(t *testing.T) {
pool := testPool(t)
advID, agencyID, userID := seedFixtures(t, pool)
s := &Store{Pool: pool}
ctx := context.Background()
created, err := s.Create(ctx, Order{
Title: "Recommended", AdvertiserID: advID, AgencyID: &agencyID, SalespersonID: userID,
Year: 2026, Quarters: []int{3, 4}, Terms: "net_45", Notes: "initial",
})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.ID == 0 || created.Status != "pending" || created.Version != 1 {
t.Fatalf("create defaults wrong: %+v", created)
}
if created.FinalizedAt != nil || created.MakegoodForOrderID != nil {
t.Fatalf("create must leave finalize/makegood fields nil: %+v", created)
}
got, err := s.Get(ctx, created.ID)
// Order embeds Quarters []int, so it isn't comparable via == — check fields,
// same convention as shows_test.go's slices.Equal(got.Weekdays, ...).
if err != nil || got.ID != created.ID || got.Title != created.Title ||
!slices.Equal(got.Quarters, created.Quarters) || got.Version != created.Version {
t.Fatalf("get = %+v (%v), want %+v", got, err, created)
}
}
func TestListFilters(t *testing.T) {
pool := testPool(t)
advID, _, userID := seedFixtures(t, pool)
s := &Store{Pool: pool}
ctx := context.Background()
other, _, otherUser := seedFixtures(t, pool) // second advertiser+user for filter contrast
_, _ = s.Create(ctx, Order{Title: "A", AdvertiserID: advID, SalespersonID: userID, Year: 2026})
_, _ = s.Create(ctx, Order{Title: "B", AdvertiserID: other, SalespersonID: otherUser, Year: 2027})
byAdv, err := s.List(ctx, Filter{AdvertiserID: advID})
if err != nil || len(byAdv) != 1 || byAdv[0].Title != "A" {
t.Errorf("filter by advertiser: %+v (%v)", byAdv, err)
}
byYear, err := s.List(ctx, Filter{Year: 2027})
if err != nil || len(byYear) != 1 || byYear[0].Title != "B" {
t.Errorf("filter by year: %+v (%v)", byYear, err)
}
all, err := s.List(ctx, Filter{})
if err != nil || len(all) != 2 {
t.Errorf("no filter: got %d orders, want 2 (%v)", len(all), err)
}
}
func TestDuplicateCopiesHeaderOnly(t *testing.T) {
pool := testPool(t)
advID, agencyID, userID := seedFixtures(t, pool)
s := &Store{Pool: pool}
ctx := context.Background()
src, err := s.Create(ctx, Order{Title: "Recommended", AdvertiserID: advID, AgencyID: &agencyID,
SalespersonID: userID, Year: 2026, Terms: "net_45", Notes: "keep me"})
if err != nil {
t.Fatal(err)
}
dup, err := s.Duplicate(ctx, src.ID)
if err != nil {
t.Fatalf("duplicate: %v", err)
}
if dup.ID == src.ID || dup.Title != src.Title || dup.Notes != src.Notes || dup.Status != "pending" {
t.Errorf("duplicate = %+v, want fresh pending copy of %+v", dup, src)
}
}
- Step 2: Run — expect FAIL (package doesn't exist / undefined symbols).
Run: cd app && just test-db (skips cleanly without TEST_DATABASE_URL; use TEST_DATABASE_URL="postgres://twitads:twitads@127.0.0.1:5445/twitads_test" go test ./internal/orders/... -v directly while iterating, then confirm via just test-db at the end).
Expected: FAIL to compile — orders package/Store/Order undefined.
- Step 3: Implement
orders.go— the struct/Filter/Storeabove plus:
package orders
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrConflict = errors.New("order modified by another user")
const orderCols = `id, title, advertiser_id, agency_id, salesperson_id, status, year, quarters,
terms, terms_text, notes, presentation, makegood_for_order_id, version, finalized_at, finalized_by`
func scanOrder(row pgx.Row) (Order, error) {
var o Order
var quarters []int32
err := row.Scan(&o.ID, &o.Title, &o.AdvertiserID, &o.AgencyID, &o.SalespersonID, &o.Status,
&o.Year, &quarters, &o.Terms, &o.TermsText, &o.Notes, &o.Presentation,
&o.MakegoodForOrderID, &o.Version, &o.FinalizedAt, &o.FinalizedBy)
for _, q := range quarters {
o.Quarters = append(o.Quarters, int(q))
}
return o, err
}
func (s *Store) Create(ctx context.Context, o Order) (Order, error) {
if o.Quarters == nil {
o.Quarters = []int{}
}
row := s.Pool.QueryRow(ctx,
`INSERT INTO orders (title, advertiser_id, agency_id, salesperson_id, year, quarters, terms, terms_text, notes, presentation)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING `+orderCols,
o.Title, o.AdvertiserID, o.AgencyID, o.SalespersonID, o.Year, o.Quarters, o.Terms, o.TermsText, o.Notes, o.Presentation)
created, err := scanOrder(row)
if err != nil {
return Order{}, fmt.Errorf("create order: %w", err)
}
return created, nil
}
func (s *Store) Get(ctx context.Context, id int64) (Order, error) {
o, err := scanOrder(s.Pool.QueryRow(ctx, `SELECT `+orderCols+` FROM orders WHERE id=$1`, id))
if err != nil {
return Order{}, fmt.Errorf("get order %d: %w", id, err)
}
return o, nil
}
func (s *Store) List(ctx context.Context, f Filter) ([]Order, error) {
q := `SELECT ` + orderCols + ` FROM orders WHERE 1=1`
var args []any
if f.AdvertiserID != 0 {
args = append(args, f.AdvertiserID)
q += fmt.Sprintf(" AND advertiser_id = $%d", len(args))
}
if f.Status != "" {
args = append(args, f.Status)
q += fmt.Sprintf(" AND status = $%d", len(args))
}
if f.Year != 0 {
args = append(args, f.Year)
q += fmt.Sprintf(" AND year = $%d", len(args))
}
q += " ORDER BY created_at DESC"
rows, err := s.Pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Order
for rows.Next() {
o, err := scanOrder(rows)
if err != nil {
return nil, err
}
out = append(out, o)
}
return out, rows.Err()
}
func (s *Store) Duplicate(ctx context.Context, id int64) (Order, error) {
src, err := s.Get(ctx, id)
if err != nil {
return Order{}, fmt.Errorf("duplicate order %d: %w", id, err)
}
return s.Create(ctx, Order{
Title: src.Title, AdvertiserID: src.AdvertiserID, AgencyID: src.AgencyID,
SalespersonID: src.SalespersonID, Year: src.Year, Quarters: src.Quarters,
Terms: src.Terms, TermsText: src.TermsText, Notes: src.Notes, Presentation: src.Presentation,
})
}
(Update with optimistic locking is deliberately Task 2, not here — it's the novel/risky logic and earns its own red-green cycle.)
- Step 4: Run — expect PASS.
Run: cd app && just test-db
Expected: internal/orders package passes (3 new tests), all prior packages still green.
- Step 5: Commit.
git add app/internal/db/migrations/00006_orders.sql app/internal/orders/
git commit -m "feat(orders): orders table + store Create/Get/List/Duplicate"
Task 2: Optimistic locking (Update)
Files:
- Modify:
app/internal/orders/orders.go - Modify:
app/internal/orders/orders_test.go
Interfaces:
func (s *Store) Update(ctx context.Context, o Order) error
o.Version must be the version the caller loaded (from a prior Get/List); on success the stored row's version increments. If another writer updated the row first (version mismatch), returns ErrConflict. If the row doesn't exist, returns a plain wrapped "not found" error (matching sponsors.UpdateAdvertiser's existing non-sentinel convention — only ErrConflict needs to be errors.Is-checkable, since it's the one case a caller must branch on).
- Step 1: Write the failing test (append to
orders_test.go):
func TestUpdateOptimisticLocking(t *testing.T) {
pool := testPool(t)
advID, _, userID := seedFixtures(t, pool)
s := &Store{Pool: pool}
ctx := context.Background()
created, err := s.Create(ctx, Order{Title: "A", AdvertiserID: advID, SalespersonID: userID, Year: 2026})
if err != nil {
t.Fatal(err)
}
// Successful update: version must be respected then incremented.
first := created
first.Title = "A revised"
if err := s.Update(ctx, first); err != nil {
t.Fatalf("first update: %v", err)
}
reloaded, err := s.Get(ctx, created.ID)
if err != nil || reloaded.Title != "A revised" || reloaded.Version != created.Version+1 {
t.Fatalf("after first update: %+v (%v)", reloaded, err)
}
// Stale update (still holding the OLD version) must conflict, not clobber.
stale := created // created.Version is now stale — reloaded already moved past it
stale.Title = "A clobber attempt"
if err := s.Update(ctx, stale); !errors.Is(err, ErrConflict) {
t.Fatalf("stale update: got %v, want ErrConflict", err)
}
unchanged, err := s.Get(ctx, created.ID)
if err != nil || unchanged.Title != "A revised" {
t.Fatalf("stale update must not have applied: %+v (%v)", unchanged, err)
}
}
func TestUpdateNotFound(t *testing.T) {
pool := testPool(t)
s := &Store{Pool: pool}
err := s.Update(context.Background(), Order{ID: 99999, Version: 1})
if err == nil || errors.Is(err, ErrConflict) {
t.Fatalf("update missing row: got %v, want a non-conflict not-found error", err)
}
}
Add "errors" to orders_test.go's import block (it only has context/slices/testing from Task 1).
- Step 2: Run — expect FAIL (
undefined: (*Store).Update, then once stubbed, both new tests fail —TestUpdateOptimisticLockingbecause nothing incrementsversionor rejects stale writes,TestUpdateNotFoundbecause there's no not-found path yet).
Run: TEST_DATABASE_URL="postgres://twitads:twitads@127.0.0.1:5445/twitads_test" go test ./internal/orders/... -run 'TestUpdateOptimisticLocking|TestUpdateNotFound' -v
Expected: FAIL for the reasons above.
- Step 3: Implement
Updateinorders.go:
func (s *Store) Update(ctx context.Context, o Order) error {
if o.Quarters == nil {
o.Quarters = []int{}
}
tag, err := s.Pool.Exec(ctx,
`UPDATE orders SET title=$3, advertiser_id=$4, agency_id=$5, salesperson_id=$6,
year=$7, quarters=$8, terms=$9, terms_text=$10, notes=$11, presentation=$12,
version=version+1, updated_at=now()
WHERE id=$1 AND version=$2`,
o.ID, o.Version, o.Title, o.AdvertiserID, o.AgencyID, o.SalespersonID,
o.Year, o.Quarters, o.Terms, o.TermsText, o.Notes, o.Presentation)
if err != nil {
return fmt.Errorf("update order %d: %w", o.ID, err)
}
if tag.RowsAffected() == 0 {
var exists bool
lookupErr := s.Pool.QueryRow(ctx, `SELECT true FROM orders WHERE id=$1`, o.ID).Scan(&exists)
if lookupErr != nil {
return fmt.Errorf("update order %d: not found", o.ID)
}
return ErrConflict
}
return nil
}
- Step 4: Run — expect PASS.
Run: cd app && just test-db
Expected: all internal/orders tests pass (5 total), everything else still green.
- Step 5: Commit.
git add app/internal/orders/orders.go app/internal/orders/orders_test.go
git commit -m "feat(orders): optimistic locking on Update (version column + ErrConflict)"
Task 3: server.go wiring + orders list UI
Files:
- Modify:
app/internal/server/server.go(addOrderStoreinterface,Orders OrderStoreonDeps,registerOrders(mux, mw, d)call) - Modify:
app/cmd/twitads/main.go(wireOrders: &orders.Store{Pool: pool}) - Create:
app/internal/server/orders_ui.go - Create:
app/internal/server/templates/orders.html - Modify:
app/internal/server/templates/home.html(replace the<!-- orders / inventory / schedule links land in B/C stages -->comment with<a href="/orders">Orders</a>, keep the comment for inventory/schedule which are still C-phase) - Modify:
app/internal/server/templates/layout.html(add three status-badge classes to the<style>block) - Create:
app/internal/server/orders_ui_test.go(fake store + list-view tests only; create/edit/duplicate handlers land in Tasks 4–5)
Interfaces:
// server.go — new interface, alongside ShowStore/SponsorStore/RateStore
type OrderStore interface {
Create(ctx context.Context, o orders.Order) (orders.Order, error)
Get(ctx context.Context, id int64) (orders.Order, error)
List(ctx context.Context, f orders.Filter) ([]orders.Order, error)
Update(ctx context.Context, o orders.Order) error
Duplicate(ctx context.Context, id int64) (orders.Order, error)
}
// Deps gains: Orders OrderStore
- Step 1: Write the failing test (
orders_ui_test.go— definefakeOrderStorefirst, matchingfakeShowStore's shape: in-memory map +nextID, one method per interface method):
package server
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"twit.tv/twitads/internal/auth"
"twit.tv/twitads/internal/orders"
)
type fakeOrderStore struct {
byID map[int64]orders.Order
nextID int64
}
func newFakeOrderStore() *fakeOrderStore {
return &fakeOrderStore{byID: map[int64]orders.Order{}, nextID: 1}
}
func (f *fakeOrderStore) Create(ctx context.Context, o orders.Order) (orders.Order, error) {
o.ID, o.Status, o.Version = f.nextID, "pending", 1
f.byID[o.ID] = o
f.nextID++
return o, nil
}
func (f *fakeOrderStore) Get(ctx context.Context, id int64) (orders.Order, error) {
o, ok := f.byID[id]
if !ok {
return orders.Order{}, errors.New("not found")
}
return o, nil
}
func (f *fakeOrderStore) List(ctx context.Context, filt orders.Filter) ([]orders.Order, error) {
var out []orders.Order
for _, o := range f.byID {
if filt.AdvertiserID != 0 && o.AdvertiserID != filt.AdvertiserID {
continue
}
if filt.Status != "" && o.Status != filt.Status {
continue
}
if filt.Year != 0 && o.Year != filt.Year {
continue
}
out = append(out, o)
}
return out, nil
}
func (f *fakeOrderStore) Update(ctx context.Context, o orders.Order) error {
cur, ok := f.byID[o.ID]
if !ok {
return errors.New("not found")
}
if cur.Version != o.Version {
return orders.ErrConflict
}
o.Version = cur.Version + 1
f.byID[o.ID] = o
return nil
}
func (f *fakeOrderStore) Duplicate(ctx context.Context, id int64) (orders.Order, error) {
src, ok := f.byID[id]
if !ok {
return orders.Order{}, errors.New("not found")
}
return f.Create(ctx, orders.Order{Title: src.Title, AdvertiserID: src.AdvertiserID, AgencyID: src.AgencyID,
SalespersonID: src.SalespersonID, Year: src.Year, Quarters: src.Quarters, Terms: src.Terms,
TermsText: src.TermsText, Notes: src.Notes, Presentation: src.Presentation})
}
func ordersServer(t *testing.T) (http.Handler, *fakeOrderStore, *fakeAudit) {
t.Helper()
store := newFakeOrderStore()
aud := &fakeAudit{}
sponsorStore := newFakeSponsorStore()
h := New(Deps{
Env: "test",
DB: fakePinger{},
Sessions: &fakeSessionStore{
fakeSessions: fakeSessions{users: map[string]auth.User{
"sales": {ID: 3, Username: "lisa", Role: auth.RoleSales, Active: true},
}},
created: map[int64]string{},
},
Users: fakeUserStore{},
Audit: aud,
Shows: newFakeShowStore(),
Sponsors: sponsorStore,
Orders: store,
})
return h, store, aud
}
func TestOrdersListVisibleToStaff(t *testing.T) {
h, store, _ := ordersServer(t)
store.byID[1] = orders.Order{ID: 1, Title: "Recommended", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
rec := getAs(h, "/orders", "sales")
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Recommended") {
t.Error("list body missing order title")
}
}
func TestOrdersListFilterByYear(t *testing.T) {
h, store, _ := ordersServer(t)
store.byID[1] = orders.Order{ID: 1, Title: "Old", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2025, Version: 1}
store.byID[2] = orders.Order{ID: 2, Title: "New", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
store.nextID = 3
rec := getAs(h, "/orders?year=2026", "sales")
body := rec.Body.String()
if strings.Contains(body, "Old") || !strings.Contains(body, "New") {
t.Errorf("year filter not applied: %s", body)
}
}
(strings is already in this file's import block above; matches how rates_ui_test.go uses strings.Contains(rec.Body.String(), want) for the same kind of body assertion.)
- Step 2: Run — expect FAIL (
Depshas no fieldOrders,orderspackage unused import,registerOrdersundefined).
Run: cd app && go test ./internal/server/... -run TestOrders -v
Expected: FAIL to compile.
- Step 3: Wire
server.go— add aboveDeps:
type OrderStore interface {
Create(ctx context.Context, o orders.Order) (orders.Order, error)
Get(ctx context.Context, id int64) (orders.Order, error)
List(ctx context.Context, f orders.Filter) ([]orders.Order, error)
Update(ctx context.Context, o orders.Order) error
Duplicate(ctx context.Context, id int64) (orders.Order, error)
}
Add "twit.tv/twitads/internal/orders" to imports, Orders OrderStore to Deps, and registerOrders(mux, mw, d) alongside the other four register* calls in New().
Implement orders_ui.go:
package server
import (
"net/http"
"strconv"
"twit.tv/twitads/internal/orders"
)
func registerOrders(mux *http.ServeMux, mw *authMiddleware, d Deps) {
mux.Handle("GET /orders", mw.requireUser(http.HandlerFunc(d.ordersList)))
}
// orderRow is the list-view model: the bare Order plus resolved names, since
// the store has no joins (no ORM) and the handler is where display-only
// name lookups belong — mirrors rateCard's showRateRow/productRow pattern.
type orderRow struct {
O orders.Order
AdvertiserName string
AgencyName string
SalespersonName string
}
type advertiserRollup struct {
Name string
Pending int
Final int
}
func (d Deps) ordersList(w http.ResponseWriter, r *http.Request) {
u, _ := CurrentUser(r.Context())
var f orders.Filter
if v := r.URL.Query().Get("advertiser"); v != "" {
f.AdvertiserID, _ = strconv.ParseInt(v, 10, 64)
}
f.Status = r.URL.Query().Get("status")
if v := r.URL.Query().Get("year"); v != "" {
y, _ := strconv.Atoi(v)
f.Year = y
}
list, err := d.Orders.List(r.Context(), f)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
advs, err := d.Sponsors.ListAdvertisers(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
advNames := map[int64]string{}
for _, a := range advs {
advNames[a.ID] = a.Name
}
agencies, err := d.Sponsors.ListAgencies(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
agencyNames := map[int64]string{}
for _, a := range agencies {
agencyNames[a.ID] = a.Name
}
users, err := d.Users.List(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
userNames := map[int64]string{}
for _, us := range users {
userNames[us.ID] = us.DisplayName
}
rollupByAdv := map[string]*advertiserRollup{}
rows := make([]orderRow, len(list))
for i, o := range list {
row := orderRow{O: o, AdvertiserName: advNames[o.AdvertiserID], SalespersonName: userNames[o.SalespersonID]}
if o.AgencyID != nil {
row.AgencyName = agencyNames[*o.AgencyID]
}
rows[i] = row
rl, ok := rollupByAdv[row.AdvertiserName]
if !ok {
rl = &advertiserRollup{Name: row.AdvertiserName}
rollupByAdv[row.AdvertiserName] = rl
}
if o.Status == "pending" {
rl.Pending++
} else if o.Status == "finalized" {
rl.Final++
}
}
var rollups []advertiserRollup
for _, rl := range rollupByAdv {
rollups = append(rollups, *rl)
}
render(w, "orders.html", pageData{User: u, Error: r.URL.Query().Get("err"), Data: map[string]any{
"Rows": rows, "Advertisers": advs, "Rollups": rollups,
"FilterAdvertiser": f.AdvertiserID, "FilterStatus": f.Status, "FilterYear": f.Year,
}})
}
templates/orders.html:
{{define "title"}}Orders — TWiT Ads{{end}}
{{define "content"}}
<p><a href="/">← Home</a></p>
<h2>Orders</h2>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<p><a href="/orders/new">+ New order</a></p>
<form method="get" action="/orders">
<label style="display:inline-block">Advertiser
<select name="advertiser">
<option value="">All</option>
{{range .Data.Advertisers}}<option value="{{.ID}}" {{if eq $.Data.FilterAdvertiser .ID}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</label>
<label style="display:inline-block">Status
<select name="status">
<option value="">All</option>
<option value="pending" {{if eq .Data.FilterStatus "pending"}}selected{{end}}>Pending</option>
<option value="finalized" {{if eq .Data.FilterStatus "finalized"}}selected{{end}}>Finalized</option>
<option value="void" {{if eq .Data.FilterStatus "void"}}selected{{end}}>Void</option>
</select>
</label>
<label style="display:inline-block">Year <input type="number" name="year" value="{{if .Data.FilterYear}}{{.Data.FilterYear}}{{end}}"></label>
<button>Filter</button>
</form>
<table>
<tr><th>Title</th><th>Advertiser</th><th>Agency</th><th>Salesperson</th><th>Status</th><th>Year</th><th></th></tr>
{{range .Data.Rows}}
<tr>
<td><a href="/orders/{{.O.ID}}">{{.O.Title}}</a></td>
<td>{{.AdvertiserName}}</td>
<td>{{if .AgencyName}}{{.AgencyName}}{{else}}<em>direct</em>{{end}}</td>
<td>{{.SalespersonName}}</td>
<td><span class="status-{{.O.Status}}">{{.O.Status}}</span></td>
<td>{{.O.Year}}</td>
<td><form class="inline" method="post" action="/orders/{{.O.ID}}/duplicate"><button>Duplicate</button></form></td>
</tr>
{{end}}
</table>
<h3>By advertiser</h3>
<table>
<tr><th>Advertiser</th><th class="num">Pending</th><th class="num">Finalized</th></tr>
{{range .Data.Rollups}}
<tr><td>{{.Name}}</td><td class="num">{{.Pending}}</td><td class="num">{{.Final}}</td></tr>
{{end}}
</table>
{{end}}
layout.html — add to the <style> block, after .error:
.status-pending { color: #0b5fa5; font-weight: 600; }
.status-finalized { color: #157a3d; font-weight: 600; }
.status-void { color: #777; font-style: italic; }
home.html — replace the placeholder comment:
<a href="/orders">Orders</a>
<!-- inventory / schedule links land in C stages -->
main.go — add "twit.tv/twitads/internal/orders" import and Orders: &orders.Store{Pool: pool}, alongside the other three store wirings.
- Step 4: Run — expect PASS.
Run: cd app && go vet ./... && go test ./internal/server/... -run TestOrders -v
Expected: PASS.
- Step 5: Commit.
git add app/internal/server/server.go app/internal/server/orders_ui.go app/internal/server/orders_ui_test.go \
app/internal/server/templates/orders.html app/internal/server/templates/home.html \
app/internal/server/templates/layout.html app/cmd/twitads/main.go
git commit -m "feat(orders): server wiring + orders list UI (status colors, filters, advertiser rollups)"
Task 4: Order create/edit UI (header form)
Files:
- Modify:
app/internal/server/orders_ui.go - Create:
app/internal/server/templates/order_form.html - Modify:
app/internal/server/orders_ui_test.go
Interfaces:
type orderFormData struct {
O orders.Order
IsNew bool
Advertisers []sponsors.Advertiser
Agencies []sponsors.Agency
Users []auth.User
}
- Step 1: Write the failing tests (append to
orders_ui_test.go):
func TestOrderCreateAndEdit(t *testing.T) {
h, store, aud := ordersServer(t)
vals := url.Values{"title": {"Recommended"}, "advertiser_id": {"1"}, "salesperson_id": {"3"},
"year": {"2026"}, "quarters": {"3,4"}, "terms": {"net_45"}, "notes": {"first draft"}, "presentation": {"lumped"}}
rec := adminPost(h, "/orders", "sales", vals)
if rec.Code != http.StatusSeeOther {
t.Fatalf("create: got %d, want 303", rec.Code)
}
if len(store.byID) != 1 {
t.Fatalf("store has %d orders, want 1", len(store.byID))
}
var created orders.Order
for _, o := range store.byID {
created = o
}
if created.Title != "Recommended" || len(created.Quarters) != 2 {
t.Errorf("created wrong: %+v", created)
}
if aud.actions[len(aud.actions)-1] != "create" {
t.Errorf("audit = %v", aud.actions)
}
editVals := url.Values{"title": {"Recommended v2"}, "advertiser_id": {"1"}, "salesperson_id": {"3"},
"year": {"2026"}, "terms": {"net_45"}, "notes": {"revised"}, "presentation": {"lumped"},
"version": {"1"}}
rec = adminPost(h, fmt.Sprintf("/orders/%d", created.ID), "sales", editVals)
if rec.Code != http.StatusSeeOther {
t.Fatalf("edit: got %d, want 303", rec.Code)
}
got, _ := store.Get(context.Background(), created.ID)
if got.Title != "Recommended v2" || got.Version != 2 {
t.Errorf("edit didn't apply: %+v", got)
}
}
func TestOrderEditConflictRerendersWithoutClobbering(t *testing.T) {
h, store, _ := ordersServer(t)
store.byID[1] = orders.Order{ID: 1, Title: "Original", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 5}
store.nextID = 2
staleVals := url.Values{"title": {"Clobber attempt"}, "advertiser_id": {"1"}, "salesperson_id": {"3"},
"year": {"2026"}, "terms": {"net_45"}, "presentation": {"lumped"}, "version": {"1"}} // stale version
rec := adminPost(h, "/orders/1", "sales", staleVals)
if rec.Code != http.StatusConflict {
t.Fatalf("got %d, want 409", rec.Code)
}
if !strings.Contains(rec.Body.String(), "modified") {
t.Error("conflict body should explain what happened")
}
got, _ := store.Get(context.Background(), 1)
if got.Title != "Original" {
t.Errorf("conflicting update must not apply: %+v", got)
}
}
(Add "fmt" and "net/url" to orders_ui_test.go's imports — context/strings are already there from Task 3's fix above.)
- Step 2: Run — expect FAIL (
orderNew/orderCreate/orderEdit/orderUpdateundefined; routes not registered).
Run: cd app && go test ./internal/server/... -run 'TestOrderCreateAndEdit|TestOrderEditConflict' -v
Expected: FAIL to compile.
- Step 3: Implement. Add routes to
registerOrders:
mux.Handle("GET /orders/new", mw.requireUser(http.HandlerFunc(d.orderNew)))
mux.Handle("POST /orders", mw.requireUser(http.HandlerFunc(d.orderCreate)))
mux.Handle("GET /orders/{id}", mw.requireUser(http.HandlerFunc(d.orderEdit)))
mux.Handle("POST /orders/{id}", mw.requireUser(http.HandlerFunc(d.orderUpdate)))
Add to orders_ui.go:
func intList(s string) []int {
var out []int
for _, part := range strings.Split(s, ",") {
p := strings.TrimSpace(part)
if p == "" {
continue
}
if n, err := strconv.Atoi(p); err == nil {
out = append(out, n)
}
}
return out
}
func orderFormLookups(r *http.Request, d Deps) (orderFormData, error) {
advs, err := d.Sponsors.ListAdvertisers(r.Context())
if err != nil {
return orderFormData{}, err
}
agencies, err := d.Sponsors.ListAgencies(r.Context())
if err != nil {
return orderFormData{}, err
}
users, err := d.Users.List(r.Context())
if err != nil {
return orderFormData{}, err
}
return orderFormData{Advertisers: advs, Agencies: agencies, Users: users}, nil
}
func orderFromForm(r *http.Request) orders.Order {
advID, _ := strconv.ParseInt(r.FormValue("advertiser_id"), 10, 64)
salespersonID, _ := strconv.ParseInt(r.FormValue("salesperson_id"), 10, 64)
year, _ := strconv.Atoi(r.FormValue("year"))
version, _ := strconv.Atoi(r.FormValue("version"))
o := orders.Order{
Title: strings.TrimSpace(r.FormValue("title")), AdvertiserID: advID, SalespersonID: salespersonID,
Year: year, Quarters: intList(r.FormValue("quarters")), Terms: r.FormValue("terms"),
TermsText: r.FormValue("terms_text"), Notes: strings.TrimSpace(r.FormValue("notes")),
Presentation: r.FormValue("presentation"), Version: version,
}
if v := strings.TrimSpace(r.FormValue("agency_id")); v != "" {
agID, _ := strconv.ParseInt(v, 10, 64)
o.AgencyID = &agID
}
return o
}
func (d Deps) orderNew(w http.ResponseWriter, r *http.Request) {
u, _ := CurrentUser(r.Context())
form, err := orderFormLookups(r, d)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
form.IsNew = true
form.O = orders.Order{SalespersonID: u.ID, Year: today().Year(), Terms: "net_45", Presentation: "lumped"}
render(w, "order_form.html", pageData{User: u, Data: form})
}
func (d Deps) orderCreate(w http.ResponseWriter, r *http.Request) {
actor, _ := CurrentUser(r.Context())
o := orderFromForm(r)
created, err := d.Orders.Create(r.Context(), o)
if err != nil {
form, lookupErr := orderFormLookups(r, d)
if lookupErr != nil {
http.Error(w, lookupErr.Error(), http.StatusInternalServerError)
return
}
form.O, form.IsNew = o, true
w.WriteHeader(http.StatusBadRequest)
render(w, "order_form.html", pageData{User: actor, Error: err.Error(), Data: form})
return
}
_ = d.Audit.Record(r.Context(), &actor, "order", fmt.Sprint(created.ID), "create",
map[string]any{"title": created.Title, "advertiser_id": created.AdvertiserID})
http.Redirect(w, r, fmt.Sprintf("/orders/%d", created.ID), http.StatusSeeOther)
}
func (d Deps) orderEdit(w http.ResponseWriter, r *http.Request) {
u, _ := CurrentUser(r.Context())
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
o, err := d.Orders.Get(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
form, err := orderFormLookups(r, d)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
form.O = o
render(w, "order_form.html", pageData{User: u, Error: r.URL.Query().Get("err"), Data: form})
}
func (d Deps) orderUpdate(w http.ResponseWriter, r *http.Request) {
actor, _ := CurrentUser(r.Context())
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
before, err := d.Orders.Get(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
o := orderFromForm(r)
o.ID = id
if err := d.Orders.Update(r.Context(), o); err != nil {
form, lookupErr := orderFormLookups(r, d)
if lookupErr != nil {
http.Error(w, lookupErr.Error(), http.StatusInternalServerError)
return
}
form.O = o
status := http.StatusBadRequest
msg := err.Error()
if errors.Is(err, orders.ErrConflict) {
status = http.StatusConflict
msg = "This order was modified by someone else since you opened it. Reload to see the latest version before retrying."
form.O, _ = d.Orders.Get(r.Context(), id) // show the current version, not the stale submission
}
w.WriteHeader(status)
render(w, "order_form.html", pageData{User: actor, Error: msg, Data: form})
return
}
_ = d.Audit.Record(r.Context(), &actor, "order", r.PathValue("id"), "update",
map[string]any{"before_title": before.Title, "title": o.Title})
http.Redirect(w, r, fmt.Sprintf("/orders/%d", id), http.StatusSeeOther)
}
orders_ui.go's import block needs to grow to cover both Task 3's and this task's code in one file: "errors", "fmt", "strings" (new in this task, for intList/orderFromForm/redirects), plus "twit.tv/twitads/internal/auth" and "twit.tv/twitads/internal/sponsors" (new — orderFormData names sponsors.Advertiser/sponsors.Agency/auth.User explicitly, unlike Task 3's code which only used inferred types from d.Sponsors/d.Users calls and never needed those imports).
templates/order_form.html:
{{define "title"}}{{if .Data.IsNew}}New Order{{else}}Edit Order{{end}} — TWiT Ads{{end}}
{{define "content"}}
<p><a href="/orders">← Orders</a></p>
<h2>{{if .Data.IsNew}}New order{{else}}Edit order{{end}}</h2>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="{{if .Data.IsNew}}/orders{{else}}/orders/{{.Data.O.ID}}{{end}}">
<input type="hidden" name="version" value="{{.Data.O.Version}}">
<label>Title <input name="title" value="{{.Data.O.Title}}" required></label>
<label>Advertiser
<select name="advertiser_id" required>
{{range .Data.Advertisers}}<option value="{{.ID}}" {{if eq $.Data.O.AdvertiserID .ID}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</label>
<label>Agency (blank = direct)
<select name="agency_id">
<option value="">— direct —</option>
{{range .Data.Agencies}}<option value="{{.ID}}" {{if and $.Data.O.AgencyID (eq $.Data.O.AgencyID .ID)}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</label>
<label>Salesperson
<select name="salesperson_id" required>
{{range .Data.Users}}<option value="{{.ID}}" {{if eq $.Data.O.SalespersonID .ID}}selected{{end}}>{{.DisplayName}}</option>{{end}}
</select>
</label>
<label>Year <input type="number" name="year" value="{{.Data.O.Year}}" required></label>
<label>Quarters (comma-separated 1-4, blank = annual) <input name="quarters" value="{{joinInts .Data.O.Quarters}}"></label>
<label>Terms
<select name="terms">
<option value="net_15" {{if eq .Data.O.Terms "net_15"}}selected{{end}}>Net 15</option>
<option value="net_30" {{if eq .Data.O.Terms "net_30"}}selected{{end}}>Net 30</option>
<option value="net_45" {{if eq .Data.O.Terms "net_45"}}selected{{end}}>Net 45</option>
<option value="net_60" {{if eq .Data.O.Terms "net_60"}}selected{{end}}>Net 60</option>
<option value="net_75" {{if eq .Data.O.Terms "net_75"}}selected{{end}}>Net 75</option>
<option value="net_90" {{if eq .Data.O.Terms "net_90"}}selected{{end}}>Net 90</option>
<option value="due_upon_receipt" {{if eq .Data.O.Terms "due_upon_receipt"}}selected{{end}}>Due upon receipt</option>
</select>
</label>
<label>Terms text (boilerplate substitution arrives in B4) <textarea name="terms_text">{{.Data.O.TermsText}}</textarea></label>
<label>Notes <textarea name="notes">{{.Data.O.Notes}}</textarea></label>
<label>Presentation
<select name="presentation">
<option value="lumped" {{if eq .Data.O.Presentation "lumped"}}selected{{end}}>Lumped</option>
<option value="below_the_line" {{if eq .Data.O.Presentation "below_the_line"}}selected{{end}}>Below the line</option>
</select>
</label>
<p><button>{{if .Data.IsNew}}Create order{{else}}Save changes{{end}}</button></p>
</form>
{{end}}
joinInts is a new template func (add to templateFuncs in templates.go, alongside joinList):
"joinInts": func(ns []int) string {
strs := make([]string, len(ns))
for i, n := range ns {
strs[i] = strconv.Itoa(n)
}
return strings.Join(strs, ",")
},
- Step 4: Run — expect PASS.
Run: cd app && go vet ./... && go test ./internal/server/... -v
Expected: PASS, including the two new tests and everything from Task 3.
- Step 5: Commit.
git add app/internal/server/orders_ui.go app/internal/server/orders_ui_test.go \
app/internal/server/templates/order_form.html app/internal/server/templates.go
git commit -m "feat(orders): create/edit header form with optimistic-locking conflict handling"
Task 5: Duplicate-order route
Files:
Modify:
app/internal/server/orders_ui.goModify:
app/internal/server/orders_ui_test.goStep 1: Write the failing test (append to
orders_ui_test.go):
func TestOrderDuplicate(t *testing.T) {
h, store, aud := ordersServer(t)
store.byID[1] = orders.Order{ID: 1, Title: "Recommended", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 3}
store.nextID = 2
rec := adminPost(h, "/orders/1/duplicate", "sales", url.Values{})
if rec.Code != http.StatusSeeOther {
t.Fatalf("got %d, want 303", rec.Code)
}
if !strings.Contains(rec.Header().Get("Location"), "/orders/2") {
t.Errorf("Location = %q, want redirect to the new order's edit page", rec.Header().Get("Location"))
}
dup, err := store.Get(context.Background(), 2)
if err != nil || dup.Title != "Recommended" || dup.Status != "pending" {
t.Errorf("duplicate = %+v (%v)", dup, err)
}
if aud.actions[len(aud.actions)-1] != "duplicate" {
t.Errorf("audit = %v", aud.actions)
}
}
- Step 2: Run — expect FAIL (route not registered,
orderDuplicateundefined).
Run: cd app && go test ./internal/server/... -run TestOrderDuplicate -v
Expected: FAIL.
- Step 3: Implement. Add to
registerOrders:
mux.Handle("POST /orders/{id}/duplicate", mw.requireUser(http.HandlerFunc(d.orderDuplicate)))
Add handler:
func (d Deps) orderDuplicate(w http.ResponseWriter, r *http.Request) {
actor, _ := CurrentUser(r.Context())
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
dup, err := d.Orders.Duplicate(r.Context(), id)
if err != nil {
http.Redirect(w, r, "/orders?err="+urlQueryEscape(err), http.StatusSeeOther)
return
}
_ = d.Audit.Record(r.Context(), &actor, "order", fmt.Sprint(dup.ID), "duplicate",
map[string]any{"duplicated_from": id, "title": dup.Title})
http.Redirect(w, r, fmt.Sprintf("/orders/%d", dup.ID), http.StatusSeeOther)
}
- Step 4: Run — expect PASS.
Run: cd app && go vet ./... && go test ./internal/server/... -v
Expected: PASS.
- Step 5: Commit.
git add app/internal/server/orders_ui.go app/internal/server/orders_ui_test.go
git commit -m "feat(orders): duplicate-order action"
Task 6: Concurrency test — two different orders, zero cross-contamination
This is the spec's explicit B2 acceptance criterion ("two browsers editing different orders simultaneously — zero cross-contamination — the legacy bug") and is distinct from Task 2's same-order optimistic-locking conflict test: this proves the stateless HTTP handler design doesn't leak data between concurrent requests for different orders (the classic legacy WebForms bug class — server-side state scoped wrong, bleeding between sessions/tabs). fakeOrderStore's map isn't safe for concurrent access as written in Task 3 — add a mutex so the test exercises the handler/middleware chain under real concurrency without the fake itself being the thing that (correctly) fails a -race run.
Files:
Modify:
app/internal/server/orders_ui_test.goStep 1: Write the failing test. First, add a mutex to
fakeOrderStore(it has none yet — Task 3's version is a bare map, fine for sequential tests but a data race under concurrent access):
type fakeOrderStore struct {
mu sync.Mutex
byID map[int64]orders.Order
nextID int64
}
Guard every method body with f.mu.Lock(); defer f.mu.Unlock() (five one-line insertions across the existing Create/Get/List/Update/Duplicate methods from Task 3). Add "sync" to imports.
Then the test itself:
func TestConcurrentEditDifferentOrdersNoCrossContamination(t *testing.T) {
h, store, _ := ordersServer(t)
store.byID[1] = orders.Order{ID: 1, Title: "Order One", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
store.byID[2] = orders.Order{ID: 2, Title: "Order Two", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
store.nextID = 3
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
vals := url.Values{"title": {"Order One EDITED"}, "advertiser_id": {"1"}, "salesperson_id": {"3"},
"year": {"2026"}, "terms": {"net_45"}, "presentation": {"lumped"}, "notes": {"from goroutine A"}, "version": {"1"}}
adminPost(h, "/orders/1", "sales", vals)
}()
go func() {
defer wg.Done()
vals := url.Values{"title": {"Order Two EDITED"}, "advertiser_id": {"1"}, "salesperson_id": {"3"},
"year": {"2026"}, "terms": {"net_45"}, "presentation": {"lumped"}, "notes": {"from goroutine B"}, "version": {"1"}}
adminPost(h, "/orders/2", "sales", vals)
}()
wg.Wait()
one, err := store.Get(context.Background(), 1)
if err != nil || one.Title != "Order One EDITED" || one.Notes != "from goroutine A" {
t.Errorf("order 1 cross-contaminated: %+v (%v)", one, err)
}
two, err := store.Get(context.Background(), 2)
if err != nil || two.Title != "Order Two EDITED" || two.Notes != "from goroutine B" {
t.Errorf("order 2 cross-contaminated: %+v (%v)", two, err)
}
}
- Step 2: Run — expect FAIL or a race report (without the mutex,
go test -racefails on the shared map; without correct per-request context handling, a cross-contamination bug would show one order carrying the other's title/notes).
Run: cd app && go test ./internal/server/... -run TestConcurrentEdit -race -v
Expected: FAIL (race detector flags the unguarded map) before Step 1's mutex is added; once the mutex is added but before confirming with a clean run, this is the point to actually watch it fail first (temporarily comment out the mutex lock to see the race, per TDD's "watch it fail for the right reason" — then restore it).
Step 3: This task's "implementation" is the mutex added in Step 1 — there's no separate production-code change beyond
orders_ui_test.go, since the actual cross-contamination safety already exists by construction (Go'snet/httpgives each request its own goroutine;Depsholds only shared, read-only-shaped store references; nothing inorders_ui.gouses a package-levelvarfor request-scoped data). This test's job is to prove that design property, not to fix a bug.Step 4: Run — expect PASS.
Run: cd app && go test ./internal/server/... -race -v
Expected: PASS, no race detected, both orders end with exactly their own submitted data.
- Step 5: Commit.
git add app/internal/server/orders_ui_test.go
git commit -m "test(orders): concurrent edits to different orders — zero cross-contamination"
Task 7: Whole-repo verification & close-out
- Step 1: Full suite, twice for idempotency.
Run: cd app && go vet ./... && just test && just test-db && just test-db
Expected: every package green both times (confirms the migration and TRUNCATE-based tests are idempotent).
- Step 2: Race check on the new concurrency-sensitive code.
Run: cd app && go test ./internal/server/... ./internal/orders/... -race -count=1
Expected: PASS, no races.
- Step 3: Compose smoke.
Run: cd app && docker compose up -d --build && sleep 3 && curl -fsS localhost:8730/healthz && docker compose logs --tail=20 app
Expected: healthz OK, migrations report v6, no errors in logs. Spot-check over the tailnet: /orders list loads, create an order, edit it, duplicate it, confirm the advertiser rollup counts update.
- Step 4: Tick the checklist below, append a "Decisions & deviations" section, update
Plans/README.mdB2 row, commit, republish.
git add -A && git commit -m "docs(B2): stage close-out — orders list + CRUD complete"
bun run scripts/publish-site.ts # then wrangler pages deploy per the env-token fast path (see Plans/HANDOFF pattern)
- Step 5: STOP for Leo's gate. Do not start B3. Per
phase-a-review.md's standing recommendation, flag this for a Fable review pass if Leo wants one before B3 (B2 introduces the first genuinely new risk class — concurrency/locking — since B1).
Acceptance checklist
-
orderstable +order_status/order_terms/order_presentationenums migrated (00006_orders.sql);makegood_for_order_id,version,finalized_at/finalized_bypresent from day one, untouched by B2'sUpdate. - Store:
Create/Get/List(with advertiser/status/year filters)/Update(optimistic locking)/Duplicate; integration tests green viajust test-db, run twice. -
Updaterejects a stale write withErrConflictwithout applying it; a genuinely-missing order gets a distinct not-found error. - Orders list: three status colors (pending/finalized/void — no red-conflict badge, that's B5), advertiser/status/year filters via GET query params, per-advertiser pending/finalized rollup counts.
- Create/edit header form: title, advertiser, agency (nullable = direct), salesperson, year+quarters, terms enum, terms text (plain, B4 wires the boilerplate substitution), notes, presentation toggle; a stale submit re-renders with a 409 and does not clobber.
- Duplicate-order copies header fields to a new pending order and redirects straight to its edit page (matching W1: Lisa duplicates then renames).
- Concurrency test: two goroutines editing two different orders simultaneously under
-race— each ends with exactly its own submitted data, zero cross-contamination. -
go vet ./...,just test,just test-db(×2) all green;-raceclean oninternal/serverandinternal/orders; compose smoke OK./ordersbrowser spot-check (needs auth) deferred to Leo, same as B1's/ratescheck.
Notes for the executor
orders.Order(this package) vsordermath.Order(B1) are different types in different packages — B2 has no line items, so nothing here callsordermath.Compute. Don't be tempted to add a dollar-total column toorders; totals are computed-over-stored starting whenever B3/B4 introduceorder_lines.- The
order_lines/order_line_datesschema is intentionally NOT part of this stage — see Domain decision #2 above for the exact shape B3 should use (episode-identity FK, not bare dates). Don't build it here even though it would be easy to bolt on; B3's title is literally "Order editor: episode lines" and should own it alongside the UI that populates it. - Optimistic locking is new to this codebase. Every other store's
Updateis "last write wins" (WHERE id=$1,RowsAffected()==0⇒ not found). Orders is the first entity where two staff can plausibly edit the same row concurrently in a way that matters (money, commitments) — don't backport this pattern to A-phase stores retroactively; it's scoped to where the risk actually is. requireUser, not a stricter gate. Every staff role can build/edit orders per spec §2's "deliberately loose" philosophy — this is a considered decision, not an oversight; B5's finalize action is where a real gate (if any) belongs.- Copy
sponsors_ui.go's andrates_ui.go's existing idioms for anything this plan didn't spell out explicitly (form re-render on error,?err=redirect pattern,today()reuse fromadmin_shows.go).
Decisions & deviations (executed 2026-07-07 by Opus 4.8, subagent-driven, Fable 5 orchestrating)
Executed via superpowers:subagent-driven-development — fresh Opus 4.8 implementer + fresh Opus 4.8 task reviewer per task, both working from an extracted task brief with no session history pasted in. All 7 tasks approved; two real bugs were found and fixed along the way, both by subagents actually running code rather than trusting a claim, which is exactly what the process is for.
- Task 1 landed as planned, with one real bug the implementer caught via its own mandated TDD cycle: the brief's
Createpassed emptyTerms/Presentationstraight intoNOT NULLenum columns, which Postgres rejects (a columnDEFAULTonly fires on omission, not on an explicit empty string) — and the brief's ownTestListFiltersdoes exactly that. Fixed by defaulting the two fields in Go to the same values the DB declares (net_45/lumped), mirroring the existingQuartersnil-guard. Independently confirmed by the task reviewer. - Task 2 (optimistic locking) landed exactly as planned. The conflict-vs-not-found disambiguation was reviewed line-by-line against the actual SQL and confirmed correct.
- Task 3 (list UI + wiring) landed with zero deviation from the brief.
- Task 4 (create/edit form) surfaced a genuine bug in the plan itself, not the implementation: the brief's agency-dropdown template expression,
{{eq $.Data.O.AgencyID .ID}}, compares a*int64against anint64. I had assumed (incorrectly) when writing the plan thathtml/template'seqbuiltin auto-dereferences pointers the same way calling a registered func does (e.g.{{mdy .OriginalDate}}with*time.Time) — it does not;eqroutes both arguments throughbasicKind, and a pointer kind never matches an int kind, so it errors at template-execution time. Every agency-backed order's edit page would have failed to render. The implementer proved this with a standalone Go repro, fixed it with a nil-safeeqPtrI64template func, and added a regression test that fails against the original expression and passes against the fix. The task reviewer independently reproduced the same repro before approving. Correction for future plans:eq/ne/comparison builtins do not auto-dereference pointer arguments — only some function-call arguments do, and that mechanism is separate. - Task 5 (duplicate-order) landed with zero functional deviation. One process nit: its commit is missing the
Co-Authored-Bytrailer every other commit in this stage has (git hygiene only, not fixed — not worth rewriting history over). - Task 6 (concurrency test) surfaced two real issues, both caught by execution rather than trust:
- The implementer correctly identified that
sync.Mutexis non-reentrant, so the brief's literal instruction ("five identical one-line insertions" ofLock()/defer Unlock()) would deadlockfakeOrderStore.Duplicate, which callsCreateinternally. Fixed by locking only the map read, unlocking, then delegating — confirmed correct (TestOrderDuplicatepasses under-race). - The first task reviewer did not accept the implementer's claimed clean
-racerun at face value and re-ran it — and found a genuineDATA RACEinfakeAudit.Record()(an unsynchronized slice append), hit by both concurrent goroutines via the handler's audit-logging call. This directly contradicted the implementer's report. A fix subagent added a mutex tofakeAudit(new commit, not amended) and confirmed no other concurrent-access risk exists package-wide. The re-review reviewer independently re-ran all three verification commands themselves and reproduced the fix's claimed output exactly (38 PASS, 0 FAIL, 0 DATA RACE) before approving.
- The implementer correctly identified that
- Task 7 (this close-out): full suite green twice (fresh migration run each time, confirming idempotency),
-raceclean oninternal/serverandinternal/orders, compose smoke OK (migrated to v6, healthz OK). The authenticated/ordersbrowser spot-check is left for Leo, same as B1's/ratescheck — no credential-guessing.
Not done here (needs Leo): the live /orders walkthrough (create/edit/duplicate/rollup counts, over the tailnet) — needs your login, same as B1's /rates check. Publish was run as part of this close-out.
Final whole-branch review (Opus 4.8, 2026-07-07) — Verdict: Ready to merge
No Critical or Important issues. Confirmed independently (not just re-reading task reviews): go build/go vet/go test -race ./internal/server/... all green on the full combined diff; the B2/B3 scope boundary (no order_lines) held everywhere; ErrConflict is handled at exactly its one reachable call site; the dynamic List filter query is fully parameterized (no injection surface); migration enum defaults match the Go-side guards.
- Heads-up for the B3 plan:
fakeSponsorStore/fakeShowStore/fakeUsersFull/fakeSessionsininternal/server's test suite are NOT concurrency-safe (unguarded maps, pointer receivers) — onlyfakeOrderStoreandfakeAuditgot mutexes, because only those two are reachable from Task 6's happy-path concurrent test. The first future concurrent test that reaches the order error/re-render path (which callsorderFormLookups→ Sponsors/Users) or a concurrent list-page test will need to guard those fakes first, or hit an unguarded-map-racefailure. - Deferred, non-blocking (Minor, ship-as-is per reviewer recommendation): (a)
Listpasses an unvalidatedstatusfilter straight into the enum column — a hand-crafted/orders?status=garbage500s (unreachable via the UI dropdown, no data risk); worth hardening whenever B5 touches this handler anyway. (b) Advertiser-rollup row order is nondeterministic (Go map iteration) — cosmetic. (c)ORDER BY created_at DESChas no id tiebreaker for same-tick orders — harmless. (d) Audit entity-ID sourcing differs cosmetically betweenorderCreate(fmt.Sprint(created.ID)) andorderUpdate(r.PathValue("id")) — same value, no functional impact.
Post-verification fix: Lisa's form feedback (Fable 5, 2026-07-07)
Lisa reviewed the live order form as part of B2 verification and asked for two changes, applied directly (small, well-scoped, TDD, same pattern as B1's post-gate commission-clamp fix):
- Quarters was a comma-separated text input; now checkboxes ("All quarters" + Q1–Q4). "All quarters" wins over any individually-checked boxes and stores the same empty-slice "annual" convention already in use — no schema change, no
Order.Quarterssemantics change, only the form widget and its parsing (quartersFromFormreplacesintList). - Year was a free number input; now a
<select>populated fromtoday().Year()-1through+2(a 4-year window). - Removed
joinInts(dead code once the comma-round-trip it served no longer exists); addedhasIntfor checkboxcheckedstate.
Also flagged by Lisa, NOT implemented here — open item for B4/B6, pending Leo's answer: Lisa described the legacy "notes" field as auto-generating from the order's terms and the show's host names, while staying editable. Leo supplied 7 real legacy order PDFs (~/Downloads/Orders/: 3Z Brands, Abine, Bitwarden, Doppel, ThreatLocker — standard broadcast orders — plus two "Opening Billboard Takeover" proposals for Informa and OutSystems) to ground the design. Findings, and the open question:
- The bottom "Notes:" section is empty on every single one of the 7 samples — no auto-generated text, no host names found anywhere across any document.
- "Terms:" (a separate section next to Notes) holds only the payment terms as short free text, worded inconsistently across orders ("Net 45 days" / "Net 45 Terms" / "Net 45 Payment Terms") — looks hand-typed, not generated from a fixed template.
- A "Cancellation Policy" paragraph appears verbatim, identically, on all 5 standard orders — this IS real fixed boilerplate (candidate content for the spec's
terms_templateentity). Confirmed by Leo: it's a default, not immutable — some orders need it edited. B2'sterms_textis already a plain editable textarea, so B4's job is just to seed it with the boilerplate (terms line + cancellation paragraph) as a starting point, same "templates are data, editable per-order" pattern the spec'sterms_templateentity already describes — no new mechanism needed, just the actual seed text once Lisa provides it. - There's also a per-line "Notes" column in the show table (separate from the bottom Notes section) — blank on every sample, but a real column B3/B4's line schema doesn't currently have.
- "Net to TWiT" is broken out per line, not just as an order total — math checks out as line cost × (1 − agency commission), e.g. Bitwarden/Airwave lines are consistently ×0.7 (Airwave's commission is 30%, not the spec's default 15% — real, useful per-agency data). Our
ordermath.Computeonly produces order-level totals today; B6's PDF may need a per-line net breakdown too. - Billboard-week orders are a structurally different document and calculation entirely. Both billboard PDFs compute the billboard price as a SUM across every show's own CPM × downloads for that week — not a single flat number. Shows highlighted yellow are explicitly called out as "included for free as added value to cover potential softness" (i.e., value-add/
IsValueAddlines), with an explicit "Less Free DLs"/"Less Free Ads" subtraction to reach "Total Guaranteed DLs"/"Total Billboard" — this maps directly ontoordermath's gross → value-add-deduct → revised flow, good validation of B1's design. But it raises a real question against A5's already-verified rate card, which stores billboard week as one flatPriceCents($17,450): the OutSystems sample's "Total Billboard: $17,450" line matches that flat rate exactly for a 1-week buy with the standard show lineup, suggesting the flat rate card price was itself derived once from this per-show math and simply entered as a shortcut — not necessarily something B4 needs to recompute live from show-level rates. Multi-week billboard orders (the Informa sample) also show a monthly Net-to-TWiT breakdown (June/July) when a buy spans a month boundary. - The word "broadcast" appears throughout every legacy PDF header ("Advertiser Broadcast order for...") — confirms, doesn't change, the already-established house rule that the new system never uses that word.
Resolved (Leo, 2026-07-07): Notes is optional, not universal. Most orders never need it — it's populated only for orders with something special worth calling out (a host-name reference, a one-off condition, etc.), which is exactly why all 7 real samples show it blank: they're standard buys with nothing unusual to note. This confirms B2's Notes field (a plain, always-editable freeform textarea) is the right shape. What's still open is the auto-generate helper itself — the actual template text and host-name-lookup logic Lisa will use when an order DOES need it — which still needs her real template text before B4 designs it. Added an HTML placeholder attribute to the Notes <textarea> (not stored data, just UI hint text) marking that as "coming," not silently blank. B4/B6 can proceed on everything else; only the auto-generate button itself waits on Lisa's template text.
Second post-verification fix: no presentation choice (Fable 5, 2026-07-07)
Leo: "you don't need a lumped choice — we will always show it below the line, no need for the choice." Removed the presentation <select> from order_form.html entirely; orderFromForm now hardcodes Presentation: "below_the_line" regardless of what a POST submits (a stale cached form or old client sending "lumped" is silently ignored, not honored — locked in by TestOrderPresentationAlwaysBelowTheLine). orderNew's default updated to match. Deliberately did NOT touch the order_presentation enum or the migration — both lumped/below_the_line values still exist at the DB level, only the application-level choice is gone, so this stays cheap to reverse if a real need for lumped ever resurfaces. B1's pure ordermath.Lumped/BelowTheLine types are untouched (harmless unused code, not worth churning already-verified B1 for a UI-only decision).
Confirmed, no plan change needed: Leo separately confirmed per-order discount stays B4's job (already correctly scoped there, see the B3 plan's decision log) and that XLSX/CSV export ("Google Sheets and Microsoft Excel") is already a standing requirement — it's already in the spec's Screens conventions ("Every grid exports XLSX/CSV"), explicitly named for sales reports ("XLSX + Sheets-friendly CSV," spec §8 D2), and for order output (B6, excelize already a required dependency per CLAUDE.md). Nothing in B2/B3 contradicts or needs to anticipate this further — it lands naturally in B6 (order exports) and D2 (report exports) when those stages get planned.
The host-names-in-notes mystery, solved (Leo, 2026-07-07) — full B4 terms boilerplate captured
Leo supplied 4 more reference orders, dated late 2024/2025 for 2025 service (~/Downloads/Orders/: Bitwarden, Abine, ExpressVPN — through Veritone Net agency, Melissa Inc — direct), explicitly labeled as "the format we will start using next year" (2027, per filenames literally saying "how 2027 terms will be"). This is a RICHER format than the 5 standard 2026-service samples reviewed earlier (which had only a one-line "Net 45 days" + the cancellation paragraph) — these 4 have the full legal terms text AND populated Notes with host names, exactly matching Leo's original description. The earlier 2026 samples were the simplified in-between format; these 2025/2027 samples are the target B4 should build toward.
The Notes section auto-generates from (at least) three pieces, all present in every one of these 4 samples:
- A fixed sentence: "Each show episode has one ad spot - a host read ad (1 - 2 minutes)." (Melissa's says "1 - 5 minutes" — so the duration may itself be a variable, not fully fixed.)
- A per-show host line, grouped by host — e.g. Bitwarden: "WW, TWIT, MBW & SN hosted by Leo Laporte"; Abine: "TWIT, MBW & SN hosted by Leo Laporte" + "TNW hosted by Mikah Sargent" (two separate lines, one per distinct host, each listing every show on the order that shares that host).
- A value-add/softness rollup sentence when the order has any value-add lines — e.g. "$45,400 - 1,115,000 impressions are included in case of softness to cover shortfalls. If not needed, they are a bonus." — one consolidated dollar figure + impressions total across every value-add line on the order, not per-line.
Confirmed against the live testbed: shows.notes (populated by Lisa/Debi in A3, domain-modeled as "hosts line for order form") already has exactly this data — e.g. SN → "Steve Gibson, Leo Laporte", WW → "Leo, Paul Thurrott, Richard Campbell", TNW → "Mikah Sargent". But there's a real subtlety B4 needs Lisa to resolve, not guessable from the data alone: several shows' notes explicitly distinguish the general host from who voices the ads — e.g. HOW → "Paul Thurrott — Leo Laporte voices ads", TWIS → "Rod Pyle — Leo Laporte voices ads". The PDFs' "hosted by Leo Laporte" phrasing looks like it names the ad-voicing host specifically (the advertiser-relevant fact — who's actually reading their copy), not the full on-air cast — which would explain why SN's PDF line says "hosted by Leo Laporte" even though shows.notes lists "Steve Gibson, Leo Laporte" (both host; only one may voice ads). Question for Lisa before B4 builds this: is there a reliable rule for deriving "the ad-voicing host" from shows.notes' free text (e.g. "whoever's named after an em-dash, else the first name listed"), or does this need its own explicit field separate from the general hosts list?
The per-line Notes column (blank in the earlier 7 samples) IS populated here, specifically on value-add lines — rich, marketing-style copy explaining why the bonus is being offered (show reach/demographics stats, e.g. "MBW covers Apple, AI, and reaches 88% tech/IT decision makers, 73% work directly in tech, & is still a top performer for B2B..."). This looks like semi-templated per-show marketing copy (the demographic stats look reusable per-show) combined with hand-typed rationale — not fully automatable from data already in the schema; likely needs its own text field on shows/products or stays manually typed per line.
Agency deals may defer the full terms text entirely — ExpressVPN (through Veritone Net) shows Terms: See Agency IO. instead of the full boilerplate, confirming the earlier "Evpn... terms are in another PDF" filename hint: for agency deals, the complete legal terms may live once at the agency level, with the advertiser-level order just pointing to it, rather than repeating ~500 words on every order.
The full terms boilerplate text, captured verbatim (identical across Bitwarden/Abine/Melissa except the {{advertiser}} substitution and the advertising-minimum figure, which was $20,000/quarter in all three) — this is real, ready-to-use content for B4's terms_template:
This broadcast order is between TWiT, LLC ("TWiT.tv") and {{advertiser}} ("Advertiser"). Advertiser to pay invoices to TWiT.tv within 30 days of the invoice date.
Each party shall treat the provisions of this broadcast order as confidential and shall not do anything which could result in the same being disclosed or used in an unauthorized manner. The advertising minimum is $20,000 per broadcast quarter.
Category Exclusivity Policy - is only extended to the show episode that the ad appears on and for the product/service that the advertiser is promoting. To obtain network exclusivity for the duration of your campaign, you must buy all shows. We do not offer host exclusivity at all.
Right of First Refusal Policy - when TWiT.tv asks Advertiser if they plan to renew, then Advertiser has 5 business days to respond or category can be filled elsewhere.
Tracking Policy - we do not allow Advertiser tracking pixels but allow Advertiser UTM tags on the show episode pages. Spotify Ad Analytics is available to all for free. Podscribe is available for $.95 CPM, as TWiT.tv does not cover the cost.
Ad Format - one host-read ad (1 - 2 minutes) that will be served in fair rotation. If the lead host is unavailable, then a co-host or in-house host will perform the ad read.
Ad Copy ("Copy") must be sent to TWiT.tv two weeks prior to advertising commencement date. Copy can be changed weekly thereafter. Updated Copy must be sent to TWiT.tv one week prior. Graphics for lower third can be changed monthly.
TWiT.tv has the right to review and approve Copy to ensure there are no conflicts with existing advertisers. Any discounts offered to the TWiT.tv audience cannot be changed unless an expiration date is provided with the offer or TWiT.tv receives a 14-day advance written notice.
Advertiser to have presence on the TWiT.tv sponsor page, show episode pages, and in RSS feed episode description. Courtesy commercials on video shows, can be provided with every ad read and used anywhere besides paid media. If advertising minimum is met, Advertiser to receive quarterly social media promotion on TWiT social media accounts.
Downloads/impressions are provided to Advertiser every Friday (unless it's a holiday or EOM is earlier that week). These will include audio and video podcast downloads compliant with IAB Podcast Measurement v2.2 Guidelines. Additionally, we add in YouTube numbers because they are tracked separately.
Downloads/impressions will be reviewed for make-good purposes approximately 30 days after the broadcast calendar month ends. If a make-good is needed, then it will be issued by TWiT.tv as soon as the Advertiser approves it. TWiT.tv reserves the right to remove ads from its content after impressions are fully delivered to Advertiser.
Cancellation Policy: All quarterly orders are noncancellable. On annual orders or orders longer than a quarter, the first quarter is noncancellable, but Advertiser may request that TWiT.tv cancel episode(s) listed in this broadcast order 90 days prior to the scheduled run date or 45 days before the next broadcast quarter.
Note for B4: this text says "broadcast order"/"broadcast calendar month" throughout — per the house rule (CLAUDE.md: the word "broadcast" never appears in UI text), the STORED/GENERATED template text should read "insertion order"/"order" instead when B4 seeds it, not copy this verbatim. Still open: whether "$20,000 per broadcast quarter" (the ad minimum) is a fixed constant or itself a variable Lisa sets per deal — all 3 direct/full-terms samples show the identical figure, but that's only 3 data points.
Resolved (Leo, 2026-07-07): only the ad-voicing host appears in the notes line, not the full cast. "Currently only Leo or Mikah can voice ads. That may change at some point." This settles the earlier open question precisely, and lets the current 13-show data be mapped now (derived from shows.notes + this rule, cross-checked against every "— X voices ads" annotation already in the data):
| Show | Ad-voicing host |
|---|---|
| Hands-On Apple, Hands-On Tech, Tech News Weekly, iOS Today | Mikah Sargent |
| Hands-On Windows, Home Theater Geeks, Intelligent Machines, MacBreak Weekly, Security Now, This Week in Space, This Week in Tech, Untitled Linux Show, Windows Weekly | Leo Laporte |
Design implication for B4 (or a small prep step before it) — don't parse this from shows.notes forever. The free text mixes general cast ("Leo, Andy Ihnatko, Jason Snell, Christina Warren") with explicit voicing annotations ("Paul Thurrott — Leo Laporte voices ads") inconsistently, and "may change at some point" means a hardcoded "if Leo or Mikah appears in the string" rule will silently break the day a third host starts voicing ads. The clean fix is a dedicated field — e.g. shows.ad_voice_host — separate from the general-cast notes field, explicit and directly editable (mirrors every other "no sentinel values, explicit field over string-parsing" decision already made in this codebase). Not implemented now — A3 is a verified foundation stage and this is squarely B4's dependency, not something blocking current testing — but the mapping above is real, current, ground-truth data ready to seed it whenever that field gets built.