diff --git a/db/schema.sql b/db/schema.sql index d1b0685..b58bcd9 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS tags ( tag_code TEXT NOT NULL UNIQUE, -- printed on QR + NFC, public identifier owner_id BIGINT REFERENCES users(id), status TEXT NOT NULL DEFAULT 'unset' - CHECK (status IN ('unset', 'active', 'suspended')), + CHECK (status IN ('unset', 'active', 'suspended', 'closed')), item_type TEXT CHECK (item_type IN ('dog', 'cat', 'baggage', 'skis', 'other')), description TEXT, photo_url TEXT, @@ -93,3 +93,7 @@ ALTER TABLE orders ADD COLUMN IF NOT EXISTS amount NUMERIC(10,2) NOT NULL DEFAUL -- Renewal tracking (lazy expiry): paid orders past renews_at are treated lapsed. ALTER TABLE orders ADD COLUMN IF NOT EXISTS renews_at TIMESTAMPTZ; + +-- Tag lifecycle: closed state (owner close / moved-from). Idempotent. +ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_status_check; +ALTER TABLE tags ADD CONSTRAINT tags_status_check CHECK (status IN ('unset','active','suspended','closed')); diff --git a/frontend/internal/handlers/scan.go b/frontend/internal/handlers/scan.go index b3ac3b0..7a161cc 100644 --- a/frontend/internal/handlers/scan.go +++ b/frontend/internal/handlers/scan.go @@ -103,6 +103,10 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc if !tag.Phone.Valid { return false, "" } + // Closed or suspended tags never alert (page shows unavailable). + if tag.Status == "closed" || tag.Status == "suspended" { + return false, "" + } // Customer pause (admin kill-switch): paused owner -> record only. if tag.OwnerID.Valid { diff --git a/frontend/internal/handlers/tag_page.go b/frontend/internal/handlers/tag_page.go index 434a2e6..380db4c 100644 --- a/frontend/internal/handlers/tag_page.go +++ b/frontend/internal/handlers/tag_page.go @@ -24,6 +24,11 @@ type publicData struct { } func (a *App) Home(w http.ResponseWriter, r *http.Request) { + // Legacy physical QR URLs: /?x=5&productid=CODE -> /t/CODE + if pid := r.URL.Query().Get("productid"); pid != "" { + http.Redirect(w, r, "/t/"+pid, http.StatusFound) + return + } a.render(w, r, "index", "Home", nil, "") } diff --git a/frontend/internal/handlers/tags.go b/frontend/internal/handlers/tags.go index 2791c4f..6b67387 100644 --- a/frontend/internal/handlers/tags.go +++ b/frontend/internal/handlers/tags.go @@ -231,6 +231,88 @@ func (a *App) UploadPhoto(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `

Photo uploaded ✓ — refresh to see it on the tag page.

`) } +// CloseTag lets an owner close (retire) their tag. Data is kept; the tag page +// shows unavailable and alerts stop (HTMX: returns account-panel). +func (a *App) CloseTag(w http.ResponseWriter, r *http.Request) { + uid, _ := auth.GetUserID(r) + tag, ok := a.loadOwnedTag(w, r, uid) + if !ok { + return + } + if tag.Status == "closed" { + a.renderAccountPanel(w, r, uid, "This tag is already closed.") + return + } + if err := a.Queries.SetTagStatus(r.Context(), db.SetTagStatusParams{ID: tag.ID, Status: "closed"}); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + a.renderAccountPanel(w, r, uid, "") +} + +// MoveTag moves a tag's details to another (unset) tag code, then closes the +// source tag — the broken-tag replacement flow (HTMX: returns account-panel). +func (a *App) MoveTag(w http.ResponseWriter, r *http.Request) { + uid, _ := auth.GetUserID(r) + src, ok := a.loadOwnedTag(w, r, uid) + if !ok { + return + } + targetCode := strings.TrimSpace(r.FormValue("new_tag_code")) // preset IDs are case-sensitive — trim only + if targetCode == "" { + a.renderAccountPanel(w, r, uid, "Enter the new tag's code.") + return + } + if targetCode == src.TagCode { + a.renderAccountPanel(w, r, uid, "The new tag must be different from the current tag.") + return + } + + target, err := a.Queries.GetTagByCode(r.Context(), targetCode) + if err != nil { + a.renderAccountPanel(w, r, uid, "That tag code wasn't found. Check the code on the new tag.") + return + } + if target.OwnerID.Valid || target.Status != "unset" { + a.renderAccountPanel(w, r, uid, "That tag is already claimed or not available.") + return + } + + // Bind the target to this owner, copy all details, then close the source. + if _, err := a.Queries.BindTag(r.Context(), db.BindTagParams{OwnerID: ownerID(uid), TagCode: targetCode}); err != nil { + a.renderAccountPanel(w, r, uid, "Could not claim the new tag.") + return + } + if _, err := a.Queries.UpdateTagDetails(r.Context(), db.UpdateTagDetailsParams{ + ID: target.ID, + ItemType: src.ItemType, + Description: src.Description, + PhotoUrl: src.PhotoUrl, + Phone: src.Phone, + Address: src.Address, + Notes: src.Notes, + SmsEnabled: src.SmsEnabled, + }); err != nil { + a.renderAccountPanel(w, r, uid, "Could not copy the details.") + return + } + if err := a.Queries.SetTagStatus(r.Context(), db.SetTagStatusParams{ID: src.ID, Status: "closed"}); err != nil { + a.renderAccountPanel(w, r, uid, "Details moved, but the old tag couldn't be closed.") + return + } + a.renderAccountPanel(w, r, uid, "") +} + +// renderAccountPanel refreshes the My Tags panel (HTMX target) with an optional error. +func (a *App) renderAccountPanel(w http.ResponseWriter, r *http.Request, uid int64, errMsg string) { + tags, err := a.Queries.ListTagsByOwner(r.Context(), ownerID(uid)) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + a.renderPartial(w, "account", "account-panel", accountData{Tags: tags, AddError: errMsg}) +} + func writeUploadMsg(w http.ResponseWriter, msg string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `

%s

`, msg) diff --git a/frontend/main.go b/frontend/main.go index a55f7ab..3d76545 100644 --- a/frontend/main.go +++ b/frontend/main.go @@ -85,6 +85,8 @@ func main() { mux.Handle("GET /account/tags/{id}/edit/inline", app.RequireAuth(app.EditInlinePage)) mux.Handle("POST /account/tags/{id}/edit", app.RequireAuth(app.EditTag)) mux.Handle("POST /account/tags/{id}/delete", app.RequireAuth(app.DeleteTag)) + mux.Handle("POST /account/tags/{id}/close", app.RequireAuth(app.CloseTag)) + mux.Handle("POST /account/tags/{id}/move", app.RequireAuth(app.MoveTag)) mux.Handle("POST /account/tags/{id}/photo", app.RequireAuth(app.UploadPhoto)) mux.HandleFunc("GET /photos/{key...}", app.ServePhoto) diff --git a/frontend/templates/tag-list.html b/frontend/templates/tag-list.html index fd8b985..921d1e2 100644 --- a/frontend/templates/tag-list.html +++ b/frontend/templates/tag-list.html @@ -17,14 +17,24 @@ {{if eq .Status "active"}}Active {{else if eq .Status "suspended"}}Suspended + {{else if eq .Status "closed"}}Closed {{else}}Unset{{end}} {{if .ItemType.Valid}}{{.ItemType.String}}{{else}}—{{end}} + {{if ne .Status "closed"}} Edit + {{end}} +
+ +
+
+ + +
{{end}} diff --git a/frontend/templates/tag-public.html b/frontend/templates/tag-public.html index 32645b4..f365a75 100644 --- a/frontend/templates/tag-public.html +++ b/frontend/templates/tag-public.html @@ -16,6 +16,12 @@

This tag is unavailable

The owner has disabled this tag. Please try another way to return the item.

+ {{else if eq .Data.Status "closed"}} +
+
🔒
+

This tag has been closed

+

This tag is no longer active. If you found this item, please try to return it another way.

+
{{else}}
diff --git a/openspec/changes/tag-lifecycle/tasks.md b/openspec/changes/tag-lifecycle/tasks.md index 27bc49e..ebfe567 100644 --- a/openspec/changes/tag-lifecycle/tasks.md +++ b/openspec/changes/tag-lifecycle/tasks.md @@ -1,20 +1,20 @@ ## 1. Schema -- [ ] 1.1 `db/schema.sql`: status CHECK gains `closed` (update CREATE + idempotent drop/re-add ALTER); `make db-up` +- [x] 1.1 `db/schema.sql`: status CHECK gains `closed` (update CREATE + idempotent drop/re-add ALTER); `make db-up` ## 2. Go -- [ ] 2.1 `shouldAlert`: return false when status `closed` or `suspended` -- [ ] 2.2 `tags.go`: `CloseTag` + `MoveTag` handlers (owner-only, HTMX panel responses) -- [ ] 2.3 `Home` compat redirect: `productid` query → `/t/{productid}` -- [ ] 2.4 Templates: tag-list Close + Move actions; tag-public closed state; routes in main.go +- [x] 2.1 `shouldAlert`: return false when status `closed` or `suspended` +- [x] 2.2 `tags.go`: `CloseTag` + `MoveTag` handlers (owner-only, HTMX panel responses) +- [x] 2.3 `Home` compat redirect: `productid` query → `/t/{productid}` +- [x] 2.4 Templates: tag-list Close + Move actions; tag-public closed state; routes in main.go ## 3. Admin -- [ ] 3.1 TagResource status options + badge include `closed` +- [x] 3.1 TagResource status options + badge include `closed` ## 4. Verification -- [ ] 4.1 New suite: close → page unavailable + no alert; move → target bound w/ copied details + source closed; move errors (owned/unset-missing/same); `/?x=5&productid=CODE` → 302 `/t/CODE` -- [ ] 4.2 Regressions (72) green; deploy -- [ ] 4.3 `openspec validate tag-lifecycle`; commit +- [x] 4.1 New suite: close → page unavailable + no alert; move → target bound w/ copied details + source closed; move errors (owned/unset-missing/same); `/?x=5&productid=CODE` → 302 `/t/CODE` +- [x] 4.2 Regressions (72) green; deploy +- [x] 4.3 `openspec validate tag-lifecycle`; commit