TWiT-Ads v2 Rebuild

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, and Plans/b-phase-handoff.md first — 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

Domain decisions (flag to Leo if wrong)

  1. orders.id is 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 via legacy_id.
  2. B2 creates orders only — no order_lines/order_line_dates table 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.md guidance, recorded here so B3 isn't surprised): order_lines needs episode_id bigint REFERENCES episodes(id) for episode_ad lines — episode identity, not a bare civil date — so a moved episode carries its sold placements with it (spec §3 item 2). billboard_week/banner_week lines instead carry week_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 to order_lines) is the natural shape; a CHECK enforcing exactly one of episode_id/week_start_date per row matches the "no sentinel values" rule.
  3. orders.status, finalized_at, finalized_by, makegood_for_order_id, and version all exist from day one but B2 only ever produces status='pending', finalized_at/by=NULL, makegood_for_order_id=NULL. Adding these columns now is cheap (per b-phase-handoff.md: "cheap now, painful later"); B5 owns the actual finalize/void/makegood transitions as dedicated store methods (mirroring shows.CancelEpisode/ReinstateEpisode being separate from the generic Update) — B2's Update never touches status.
  4. quarters+year modeled as year int + quarters int[] (subset of 1-4, empty = annual/unspecified), mirroring shows.Weekdays's exact int[] + CHECK convention. Spec: "display metadata; real scheduling is the line dates" — this is intentionally inert beyond display, no business logic reads it.
  5. terms_text/presentation/notes are plain editable fields in B2's form, not yet wired to the terms-template substitution engine or ordermath.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.
  6. salesperson_id is 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 defaults salesperson_id to the current logged-in user, editable via a dropdown of all users.
  7. All order routes gated requireUser (any authenticated staff), matching sponsors_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.)
  8. 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:

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)
// 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)
	}
}

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.

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.)

Run: cd app && just test-db Expected: internal/orders package passes (3 new tests), all prior packages still green.

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:

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).

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).

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.

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
}

Run: cd app && just test-db Expected: all internal/orders tests pass (5 total), everything else still green.

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:

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
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.)

Run: cd app && go test ./internal/server/... -run TestOrders -v Expected: FAIL to compile.

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.

Run: cd app && go vet ./... && go test ./internal/server/... -run TestOrders -v Expected: PASS.

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:

Interfaces:

type orderFormData struct {
	O           orders.Order
	IsNew       bool
	Advertisers []sponsors.Advertiser
	Agencies    []sponsors.Agency
	Users       []auth.User
}
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.)

Run: cd app && go test ./internal/server/... -run 'TestOrderCreateAndEdit|TestOrderEditConflict' -v Expected: FAIL to compile.

	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, ",")
	},

Run: cd app && go vet ./... && go test ./internal/server/... -v Expected: PASS, including the two new tests and everything from Task 3.

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:

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)
	}
}

Run: cd app && go test ./internal/server/... -run TestOrderDuplicate -v Expected: FAIL.

	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)
}

Run: cd app && go vet ./... && go test ./internal/server/... -v Expected: PASS.

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:

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)
	}
}

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).

Run: cd app && go test ./internal/server/... -race -v Expected: PASS, no race detected, both orders end with exactly their own submitted data.

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

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).

Run: cd app && go test ./internal/server/... ./internal/orders/... -race -count=1 Expected: PASS, no races.

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.

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)

Acceptance checklist


Notes for the executor

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.

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.

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):

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:

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:

  1. 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.)
  2. 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).
  3. 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.