From e3bcd2e6e0582dcf2b230ff7c562f2cd6cb67b2f Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Fri, 7 Aug 2026 16:56:50 +1000 Subject: [PATCH] tag-registry-product-link: seed 100 real preset IDs (seed-registry cmd), products/orders schema + tag linkage, fix case-sensitive tag-code bind --- Makefile | 7 +- db/schema.sql | 23 ++++++ frontend/cmd/seed-registry/main.go | 75 +++++++++++++++++++ frontend/internal/db/models.go | 19 +++++ frontend/internal/db/queries.sql.go | 28 +++++-- frontend/internal/handlers/tags.go | 3 +- .../specs/tag-registry/spec.md | 6 +- .../tag-registry-product-link/tasks.md | 16 ++-- todo.txt | 1 + 9 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 frontend/cmd/seed-registry/main.go diff --git a/Makefile b/Makefile index 9cccef5..2c87e23 100644 --- a/Makefile +++ b/Makefile @@ -4,14 +4,17 @@ SESSION_SECRET ?= dev-secret-change-me # dev only — set a real secret in pro export DATABASE_URL export SESSION_SECRET -.PHONY: db-up seed run build generate psql +.PHONY: db-up seed seed-registry run build generate psql db-up: ## apply db/schema.sql (embedded migrator, no psql needed) cd frontend && go run ./cmd/migrate -schema ../db/schema.sql -seed: ## insert test tag codes TEST000001..TEST000010 +seed: ## insert test tag codes TEST000001..TEST000025 cd frontend && go run ./cmd/seed +seed-registry: ## insert the 100 real preset tag IDs (anti-scam registry) + cd frontend && go run ./cmd/seed-registry -registry ../db/preset_tag_ids.txt + run: ## run the web server (port 3020) cd frontend && go run . diff --git a/db/schema.sql b/db/schema.sql index 96963e6..b11031f 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -40,3 +40,26 @@ CREATE TABLE IF NOT EXISTS scans ( -- Phase 2: per-tag SMS alert control. Idempotent so existing DBs migrate cleanly. ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_enabled BOOLEAN NOT NULL DEFAULT TRUE; + +-- Phase 2.5: product templates + sales orders (mirrors 2014 products/online_orders model). +CREATE TABLE IF NOT EXISTS products ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + sku TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + item_type TEXT CHECK (item_type IN ('dog', 'cat', 'baggage', 'skis', 'other')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS orders ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + account_id BIGINT REFERENCES users(id), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'paid', 'lapsed', 'cancelled')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Tag → product / order linkage (populated by the Laravel admin, Phase 4). +ALTER TABLE tags ADD COLUMN IF NOT EXISTS product_id BIGINT REFERENCES products(id); +ALTER TABLE tags ADD COLUMN IF NOT EXISTS order_id BIGINT REFERENCES orders(id); diff --git a/frontend/cmd/seed-registry/main.go b/frontend/cmd/seed-registry/main.go new file mode 100644 index 0000000..173672a --- /dev/null +++ b/frontend/cmd/seed-registry/main.go @@ -0,0 +1,75 @@ +// Command seed-registry inserts the real preset tag IDs from +// db/preset_tag_ids.txt into the tags table (the anti-scam registry). +// Idempotent: already-existing IDs are skipped. +// Usage: go run ./cmd/seed-registry -registry ../db/preset_tag_ids.txt +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "os" + "strings" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "wherewoof/frontend/internal/db" +) + +func main() { + registryPath := flag.String("registry", "../db/preset_tag_ids.txt", "path to preset tag ID file") + flag.Parse() + + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + fmt.Fprintln(os.Stderr, "error: DATABASE_URL not set") + os.Exit(1) + } + + f, err := os.Open(*registryPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error opening registry:", err) + os.Exit(1) + } + defer f.Close() + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + fmt.Fprintln(os.Stderr, "connect:", err) + os.Exit(1) + } + defer pool.Close() + + q := db.New(pool) + seeded, skipped, errs := 0, 0, 0 + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if _, err := q.InsertTag(ctx, line); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + skipped++ + continue + } + fmt.Fprintln(os.Stderr, "insert", line, ":", err) + errs++ + continue + } + seeded++ + } + if err := sc.Err(); err != nil { + fmt.Fprintln(os.Stderr, "read:", err) + os.Exit(1) + } + fmt.Printf("registry seed: %d inserted, %d already existed, %d errors\n", seeded, skipped, errs) + if errs > 0 { + os.Exit(1) + } +} diff --git a/frontend/internal/db/models.go b/frontend/internal/db/models.go index 7ee6e36..8c392c8 100644 --- a/frontend/internal/db/models.go +++ b/frontend/internal/db/models.go @@ -8,6 +8,23 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type Order struct { + ID int64 `json:"id"` + AccountID pgtype.Int8 `json:"account_id"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type Product struct { + ID int64 `json:"id"` + Sku string `json:"sku"` + Name string `json:"name"` + ItemType pgtype.Text `json:"item_type"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type Scan struct { ID int64 `json:"id"` TagID int64 `json:"tag_id"` @@ -33,6 +50,8 @@ type Tag struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` SmsEnabled bool `json:"sms_enabled"` + ProductID pgtype.Int8 `json:"product_id"` + OrderID pgtype.Int8 `json:"order_id"` } type User struct { diff --git a/frontend/internal/db/queries.sql.go b/frontend/internal/db/queries.sql.go index b3a8733..b0c85d7 100644 --- a/frontend/internal/db/queries.sql.go +++ b/frontend/internal/db/queries.sql.go @@ -15,7 +15,7 @@ const bindTag = `-- name: BindTag :one UPDATE tags SET owner_id = $1, updated_at = now() WHERE tag_code = $2 AND owner_id IS NULL AND status = 'unset' -RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled +RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id ` type BindTagParams struct { @@ -40,6 +40,8 @@ func (q *Queries) BindTag(ctx context.Context, arg BindTagParams) (Tag, error) { &i.CreatedAt, &i.UpdatedAt, &i.SmsEnabled, + &i.ProductID, + &i.OrderID, ) return i, err } @@ -48,7 +50,7 @@ const clearTagOwner = `-- name: ClearTagOwner :one UPDATE tags SET owner_id=NULL, status='unset', item_type=NULL, description=NULL, photo_url=NULL, phone=NULL, address=NULL, notes=NULL, updated_at=now() WHERE id=$1 -RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled +RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id ` func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) { @@ -68,6 +70,8 @@ func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) { &i.CreatedAt, &i.UpdatedAt, &i.SmsEnabled, + &i.ProductID, + &i.OrderID, ) return i, err } @@ -162,7 +166,7 @@ func (q *Queries) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, er } const getTagByCode = `-- name: GetTagByCode :one -SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled FROM tags WHERE tag_code = $1 +SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id FROM tags WHERE tag_code = $1 ` func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error) { @@ -182,12 +186,14 @@ func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error) &i.CreatedAt, &i.UpdatedAt, &i.SmsEnabled, + &i.ProductID, + &i.OrderID, ) return i, err } const getTagByID = `-- name: GetTagByID :one -SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled FROM tags WHERE id = $1 +SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id FROM tags WHERE id = $1 ` func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) { @@ -207,6 +213,8 @@ func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) { &i.CreatedAt, &i.UpdatedAt, &i.SmsEnabled, + &i.ProductID, + &i.OrderID, ) return i, err } @@ -285,7 +293,7 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e const insertTag = `-- name: InsertTag :one INSERT INTO tags (tag_code) VALUES ($1) -RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled +RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id ` func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) { @@ -305,12 +313,14 @@ func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) { &i.CreatedAt, &i.UpdatedAt, &i.SmsEnabled, + &i.ProductID, + &i.OrderID, ) return i, err } const listTagsByOwner = `-- name: ListTagsByOwner :many -SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled FROM tags WHERE owner_id = $1 ORDER BY created_at DESC +SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id FROM tags WHERE owner_id = $1 ORDER BY created_at DESC ` func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error) { @@ -336,6 +346,8 @@ func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]T &i.CreatedAt, &i.UpdatedAt, &i.SmsEnabled, + &i.ProductID, + &i.OrderID, ); err != nil { return nil, err } @@ -393,7 +405,7 @@ const updateTagDetails = `-- name: UpdateTagDetails :one UPDATE tags SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, sms_enabled=$8, status='active', updated_at=now() WHERE id=$1 -RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled +RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id ` type UpdateTagDetailsParams struct { @@ -433,6 +445,8 @@ func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsPara &i.CreatedAt, &i.UpdatedAt, &i.SmsEnabled, + &i.ProductID, + &i.OrderID, ) return i, err } diff --git a/frontend/internal/handlers/tags.go b/frontend/internal/handlers/tags.go index e1bfc9e..6571e10 100644 --- a/frontend/internal/handlers/tags.go +++ b/frontend/internal/handlers/tags.go @@ -37,7 +37,8 @@ func (a *App) Account(w http.ResponseWriter, r *http.Request) { // AddTag binds a tag code to the current account (HTMX: returns account-panel). func (a *App) AddTag(w http.ResponseWriter, r *http.Request) { uid, _ := auth.GetUserID(r) - code := strings.ToUpper(strings.TrimSpace(r.FormValue("tag_code"))) + // Tag codes are case-sensitive (preset base64 IDs) — trim only, never normalise case. + code := strings.TrimSpace(r.FormValue("tag_code")) oid := ownerID(uid) data := accountData{} diff --git a/openspec/changes/tag-registry-product-link/specs/tag-registry/spec.md b/openspec/changes/tag-registry-product-link/specs/tag-registry/spec.md index 2732ac5..9257c84 100644 --- a/openspec/changes/tag-registry-product-link/specs/tag-registry/spec.md +++ b/openspec/changes/tag-registry-product-link/specs/tag-registry/spec.md @@ -12,7 +12,7 @@ The system SHALL provide a `seed-registry` command that reads `db/preset_tag_ids - **THEN** no duplicate `tag_code` errors occur and the registry is unchanged ### Requirement: Registry-only claimable codes -Only codes present in the `tags` table (the registry) SHALL be claimable. A code not in the registry SHALL be rejected (existing bind behaviour). +Only codes present in the `tags` table (the registry) SHALL be claimable. A code not in the registry SHALL be rejected (existing bind behaviour). Tag codes SHALL be treated as case-sensitive (preset base64 IDs are mixed-case; the bind handler SHALL NOT normalise case). #### Scenario: Real ID claimed - **WHEN** an owner enters one of the 100 real preset IDs @@ -22,6 +22,10 @@ Only codes present in the `tags` table (the registry) SHALL be claimable. A code - **WHEN** an owner enters a made-up code - **THEN** binding is rejected with "Tag not found, or already claimed" +#### Scenario: Mixed-case ID not uppercased +- **WHEN** an owner enters a preset ID containing lowercase letters +- **THEN** the bind matches the exact (case-sensitive) registry row + ### Requirement: DEV test codes coexist The existing `TEST000001..25` seed SHALL remain available for local development, separate from the real registry seed. diff --git a/openspec/changes/tag-registry-product-link/tasks.md b/openspec/changes/tag-registry-product-link/tasks.md index 342855e..a43faa5 100644 --- a/openspec/changes/tag-registry-product-link/tasks.md +++ b/openspec/changes/tag-registry-product-link/tasks.md @@ -1,16 +1,16 @@ ## 1. Schema -- [ ] 1.1 `db/schema.sql`: add `products` and `orders` tables; add idempotent `ALTER TABLE tags ADD COLUMN IF NOT EXISTS product_id BIGINT REFERENCES products(id)` and `order_id BIGINT REFERENCES orders(id)`; run `make db-up` -- [ ] 1.2 `make generate` (sqlc adds `Product`/`Order` models, extends `Tag`); verify build +- [x] 1.1 `db/schema.sql`: add `products` and `orders` tables; add idempotent `ALTER TABLE tags ADD COLUMN IF NOT EXISTS product_id BIGINT REFERENCES products(id)` and `order_id BIGINT REFERENCES orders(id)`; run `make db-up` +- [x] 1.2 `make generate` (sqlc adds `Product`/`Order` models, extends `Tag`); verify build ## 2. Registry Seed -- [ ] 2.1 `frontend/cmd/seed-registry/main.go`: read `db/preset_tag_ids.txt` (skip comment lines), `InsertTag` each ID, skip unique-violations, report counts -- [ ] 2.2 Makefile `seed-registry` target (cwd frontend, schema path aware); README note +- [x] 2.1 `frontend/cmd/seed-registry/main.go`: read `db/preset_tag_ids.txt` (skip comment lines), `InsertTag` each ID, skip unique-violations, report counts +- [x] 2.2 Makefile `seed-registry` target (cwd frontend, schema path aware); README note ## 3. Verification -- [ ] 3.1 `make seed-registry` on fresh DB → 100 tags; re-run → no errors (idempotent) -- [ ] 3.2 Bind a real ID (register owner, add tag with one of the 100 IDs) → bound; made-up code → rejected -- [ ] 3.3 Existing suites still pass (`/tmp/verify.sh` Phase 1 + `/tmp/verify2.sh` Phase 2) with TEST codes -- [ ] 3.4 `openspec validate tag-registry-product-link`; commit +- [x] 3.1 `make seed-registry` on fresh DB → 100 tags; re-run → no errors (idempotent) +- [x] 3.2 Bind a real ID (register owner, add tag with one of the 100 IDs) → bound; made-up code → rejected +- [x] 3.3 Existing suites still pass (`/tmp/verify.sh` Phase 1 + `/tmp/verify2.sh` Phase 2) with TEST codes +- [x] 3.4 `openspec validate tag-registry-product-link`; commit diff --git a/todo.txt b/todo.txt index 255cddb..c500ba2 100644 --- a/todo.txt +++ b/todo.txt @@ -8,3 +8,4 @@ x 2026-08-05 Set up Gitea for where_woof +project:where-woof +agent:where_woof (B) Scan-flow: re-alert when different finder phone within 250 m window +project:where-woof +agent:right-monitor-pi-coding (B) Scan-flow: browser fingerprint + 24 h block, store in DB +project:where-woof +agent:right-monitor-pi-coding +(B) Preset tag-ID registry: seed real manufactured IDs, enforce registry-only (anti-scam) +project:where-woof +agent:right-monitor-pi-coding