From eb7f8a200c36b6b186afe07b2bebfa69e7361be6 Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 19 Oct 2026 07:02:00 -0400 Subject: [PATCH] feat(needs): per-user dismiss/defer storage and the needs/snooze routes (TASK-3918) (#2231) IDEA-3906 half A, unit A2. - Migrations 153 (SQLite) and 122 (Postgres) add item_needs_snoozes, one row per (user, item), FKs cascading, indexed on item_id. - A deferral holds until defer_until (an RFC3339 time after now and within 366 days), whatever changes before then (IDEA-3906 A2). A fractional time is stored rounded up to the next whole second, never early. - A dismissal holds until something happens after its marker: a live comment by anyone else newer than seen_comment_at, another such comment at that same second (seen_comment_ids), or a later status transition (MAX(status_transitions.seq); items.seq and updated_at don't move on a comment). The user's own human comments never break it; an agent's comments under their credentials do, and so does the user's own status change. Imported comments are history and never break it. - The marker comes from the client, clamped to the server's values so it can only claim less was seen. No marker means the current state. - PUT|DELETE /api/v1/workspaces/{ws}/items/{slug}/needs/snooze, beside stars and watches: requireItemVisible (404 when hidden) and no edit check, so viewers and item-grant guests may snooze. Read-scoped tokens are refused by tokenScopeAllows. DELETE is idempotent. - nulcolumns classifies the table's TEXT columns as server-written. - A migrate-to-pg marked SQLite file refuses snooze writes: the table joins migratedRefusalTables, since models.WorkspaceExport does not carry it. TestMigratedDatabaseRefusesNeedsSnoozeWrites drives the store's own verbs rather than iterating the list, so removing the entry goes red. - Workspace purge relies on the FK cascade; the purge census (wsChildTables, seedFullWorkspace) now counts item_needs_snoozes. - docs/api.md documents the endpoint. Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF --- docs/api.md | 1 + internal/models/needs.go | 31 ++ internal/server/handlers_needs_snooze.go | 108 ++++++ internal/server/handlers_needs_snooze_test.go | 151 +++++++++ internal/server/server.go | 4 + internal/store/migrated.go | 3 + internal/store/migrated_test.go | 45 +++ .../migrations/153_item_needs_snoozes.sql | 32 ++ internal/store/needs_snooze.go | 240 +++++++++++++ internal/store/needs_snooze_test.go | 318 ++++++++++++++++++ internal/store/nulcolumns.go | 8 + .../pgmigrations/122_item_needs_snoozes.sql | 16 + internal/store/workspace_purge_test.go | 5 + 13 files changed, 962 insertions(+) create mode 100644 internal/models/needs.go create mode 100644 internal/server/handlers_needs_snooze.go create mode 100644 internal/server/handlers_needs_snooze_test.go create mode 100644 internal/store/migrations/153_item_needs_snoozes.sql create mode 100644 internal/store/needs_snooze.go create mode 100644 internal/store/needs_snooze_test.go create mode 100644 internal/store/pgmigrations/122_item_needs_snoozes.sql diff --git a/docs/api.md b/docs/api.md index 4839e95e1..79a056e84 100644 --- a/docs/api.md +++ b/docs/api.md @@ -82,6 +82,7 @@ REST API at `/api/v1/`. Key endpoints: - `GET /workspaces/{ws}/changes?since=` — the web sync delta: items updated at or after `since` (archived ones included; their ids are also in `deleted`), with full bodies, and `server_time`, the next `since`. **Bounded and paged by a cursor** (BUG-3791). `limit` defaults to, and is capped at, 1000, as on the item lists. When more remain the body says `has_more: true` with `next_cursor`; ask again with `?cursor=` alone (a `since` beside it must equal the walk's). The cursor is a position in the workspace's write order (`seq`, then id), so a change committed during the walk comes back in a later page, and a row can appear twice. Every page repeats the FIRST page's `server_time`, which is a safe next `since` only once a page says `has_more: false`. Visibility is applied before the window, so a page's count and `has_more` never reflect rows the caller cannot see. **Contract change:** a client that ignores `has_more` gets the first 1000 changes of a longer delta and loses the rest when it advances to `server_time`; the web client (`walkChanges`) walks every page, and fails to a full refresh past 50 - `GET /workspaces/{ws}/items/{slug}/children` — child items linked to a parent, in the manual order (sort_order, created_at, id). **Bounded and paged like the item lists** (BUG-3789): `limit` defaults to and is capped at 1000, `offset` skips rows, and `X-Pad-Has-More` / `X-Pad-Next-Offset` carry the paging state; the body stays a bare array. Visibility is applied before the window, so the page and has-more count only children the caller may see. The web client walks every page - `GET /workspaces/{ws}/starred` — the caller's starred items, most recently starred first (ties by id); `include_terminal=true` keeps closed ones, `summary=true` leaves out bodies. **Bounded and paged** the same way (BUG-3790), after the visibility and terminal filters; bodies are loaded only for the page returned. `pad item starred` and the web client walk every page +- `PUT|DELETE /workspaces/{ws}/items/{slug}/needs/snooze` — the caller's own dismissal or deferral of an item in their needs-you inbox (TASK-3918, IDEA-3906). Per user: nobody else's view changes, so any person who can see the item may snooze it, viewers and item-grant guests included. An item the caller cannot see is 404, and a read-scoped token is refused 403 (these are writes). PUT body `{kind: "dismiss"|"defer", defer_until?, marker?}` replaces any earlier snooze and returns `{ref, snooze: {item_id, workspace_id, kind, defer_until?, marker, created_at, holds}}`. `defer_until` is an RFC3339 time after now and within 366 days, or 400 `validation_error`, as is an unknown `kind`; a fractional time is stored rounded up to the next whole second. A deferral hides the item until that time, whatever changes before then. A dismissal hides it until something happens after the `marker` (`{seen_comment_at, seen_comment_ids, seen_transition_seq}`, what the caller had seen): a live comment by anyone else newer than `seen_comment_at`, another such comment at that same second, or a later status transition (a round trip back to the same status counts). Only the caller's own human comments are exempt: their own status change brings it back like anyone's, and so do an agent's comments under their credentials. Imported comments are history and do not count. With no `marker` the server uses the item's current state. A marker is clamped to the server's values, so it can only claim less was seen. `holds` is evaluated when read. DELETE removes the caller's snooze and answers `{ref, snooze: null}`, also when there was none. Storage: `item_needs_snoozes` (migration 153 / Postgres 122). The reader is the needs-you inbox, `GET /api/v1/me/needs` (TASK-3920). Until it lands, a snooze is stored and reported but nothing filters on it, and catch-up's `needs_you` section does not read it. - `GET /api/v1/s/{token}` — public share link resolution. For a COLLECTION share the items are **bounded and paged** (BUG-3792): `limit` (default and cap 1000) and `offset` query parameters, and the body carries `has_more` and `next_offset` (the headers are set too). The web share page follows `next_offset` until `has_more` is false and merges the pages' items and `attachment_refs` - `GET /workspaces/{ws}/items/{slug}/progress` — child item completion progress - `GET/POST /workspaces/{ws}/items/{slug}/links` — item relationships (blocks/blocked-by, parent/child) diff --git a/internal/models/needs.go b/internal/models/needs.go new file mode 100644 index 000000000..ffd7ea76c --- /dev/null +++ b/internal/models/needs.go @@ -0,0 +1,31 @@ +package models + +// NeedsSnoozeMarker is what a person had seen of an item when they dismissed +// it from their needs-you inbox (TASK-3918, IDEA-3906): the newest comment +// by anyone else, the ids of the comments at that same second, and the +// item's latest status-transition seq. The server clamps a client's marker +// to its own values, so a marker can only ever claim LESS was seen. +type NeedsSnoozeMarker struct { + SeenCommentAt string `json:"seen_comment_at"` + SeenCommentIDs []string `json:"seen_comment_ids"` + SeenTransitionSeq int64 `json:"seen_transition_seq"` +} + +// NeedsSnooze is one person's dismissal or deferral of one item. +type NeedsSnooze struct { + ItemID string `json:"item_id"` + WorkspaceID string `json:"workspace_id"` + Kind string `json:"kind"` // NeedsSnoozeDismiss or NeedsSnoozeDefer + DeferUntil string `json:"defer_until,omitempty"` + Marker NeedsSnoozeMarker `json:"marker"` + CreatedAt string `json:"created_at"` + // Holds reports whether the snooze still hides the item, computed at + // read time: a deferral until its date, a dismissal until something + // happens on the item after the marker. + Holds bool `json:"holds"` +} + +const ( + NeedsSnoozeDismiss = "dismiss" + NeedsSnoozeDefer = "defer" +) diff --git a/internal/server/handlers_needs_snooze.go b/internal/server/handlers_needs_snooze.go new file mode 100644 index 000000000..d92aad80b --- /dev/null +++ b/internal/server/handlers_needs_snooze.go @@ -0,0 +1,108 @@ +package server + +import ( + "errors" + "net/http" + "time" + + "github.com/PerpetualSoftware/pad/internal/models" + "github.com/PerpetualSoftware/pad/internal/store" + "github.com/go-chi/chi/v5" +) + +// Needs-you snoozes (TASK-3918, IDEA-3906 half A unit A2): the caller hides +// an item from their own needs-you inbox. Per user, so any person who can +// see the item may snooze it: viewers and guests included, with no edit +// check. A read-scoped token is refused by tokenScopeAllows (PUT and DELETE +// are writes), and an item the caller cannot see is 404. + +type needsSnoozeRequest struct { + Kind string `json:"kind"` + DeferUntil string `json:"defer_until,omitempty"` + Marker *models.NeedsSnoozeMarker `json:"marker,omitempty"` +} + +// resolveNeedsSnoozeItem is the shared front half: the item, visible to the +// caller, and the caller's user id. It has written the response when ok is +// false. +func (s *Server) resolveNeedsSnoozeItem(w http.ResponseWriter, r *http.Request) (workspaceID string, item *models.Item, userID string, ok bool) { + workspaceID, ok = s.getWorkspaceID(w, r) + if !ok { + return "", nil, "", false + } + itemSlug := chi.URLParam(r, "itemSlug") + item, err := s.store.ResolveItem(workspaceID, itemSlug) + if err != nil { + writeInternalError(w, err) + return "", nil, "", false + } + if item == nil { + s.writeItemResolveError(w, r, workspaceID, itemSlug) + return "", nil, "", false + } + if !s.requireItemVisible(w, r, workspaceID, item) { + return "", nil, "", false + } + userID = currentUserID(r) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return "", nil, "", false + } + return workspaceID, item, userID, true +} + +// handleSnoozeNeedsItem dismisses or defers an item for the caller, +// replacing any earlier snooze. +// PUT /api/v1/workspaces/{slug}/items/{itemSlug}/needs/snooze +// Body: {"kind": "dismiss"|"defer", "defer_until"?: RFC3339, "marker"?: {...}} +func (s *Server) handleSnoozeNeedsItem(w http.ResponseWriter, r *http.Request) { + workspaceID, item, userID, ok := s.resolveNeedsSnoozeItem(w, r) + if !ok { + return + } + var req needsSnoozeRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid JSON body") + return + } + var until time.Time + if req.Kind == models.NeedsSnoozeDefer { + t, err := time.Parse(time.RFC3339, req.DeferUntil) + if err != nil { + writeError(w, http.StatusBadRequest, "validation_error", "defer_until must be an RFC3339 time") + return + } + until = t + } + sn, err := s.store.SnoozeNeedsItem(userID, workspaceID, item.ID, req.Kind, until, req.Marker) + if errors.Is(err, store.ErrInvalidNeedsSnooze) { + writeError(w, http.StatusBadRequest, "validation_error", err.Error()) + return + } + if err != nil { + writeInternalError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "ref": item.Ref, + "snooze": sn, + }) +} + +// handleUnsnoozeNeedsItem removes the caller's snooze, so the item shows in +// their inbox again. Idempotent: no snooze is not an error. +// DELETE /api/v1/workspaces/{slug}/items/{itemSlug}/needs/snooze +func (s *Server) handleUnsnoozeNeedsItem(w http.ResponseWriter, r *http.Request) { + _, item, userID, ok := s.resolveNeedsSnoozeItem(w, r) + if !ok { + return + } + if _, err := s.store.UnsnoozeNeedsItem(userID, item.ID); err != nil { + writeInternalError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "ref": item.Ref, + "snooze": nil, + }) +} diff --git a/internal/server/handlers_needs_snooze_test.go b/internal/server/handlers_needs_snooze_test.go new file mode 100644 index 000000000..a16dde2c2 --- /dev/null +++ b/internal/server/handlers_needs_snooze_test.go @@ -0,0 +1,151 @@ +package server + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// TestNeedsSnoozeRoute_Visibility drives PUT|DELETE .../needs/snooze through +// the router (TASK-3918): a viewer and an item-grant guest may snooze, a +// member restricted away from the item's collection and a stranger get 404 +// and write nothing, and a read-scoped token is refused 403. +func TestNeedsSnoozeRoute_Visibility(t *testing.T) { + t.Parallel() + srv := testServer(t) + slug := createWSWithCollections(t, srv) + rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", + map[string]interface{}{"title": "Asked", "fields": `{"status":"open"}`}) + if rr.Code != http.StatusCreated { + t.Fatalf("seed item: %d %s", rr.Code, rr.Body.String()) + } + var item models.Item + parseJSON(t, rr, &item) + ws, err := srv.store.GetWorkspaceBySlug(slug) + if err != nil { + t.Fatal(err) + } + ideas, err := srv.store.GetCollectionBySlug(ws.ID, "ideas") + if err != nil || ideas == nil { + t.Fatalf("ideas collection: %v", err) + } + + user := func(email string) *models.User { + u, err := srv.store.CreateUser(models.UserCreate{Email: email, Name: email, Password: "pw-test-12345"}) + if err != nil { + t.Fatal(err) + } + return u + } + token := func(u *models.User, scopes string) string { + tok, err := srv.store.CreateAPIToken(u.ID, models.APITokenCreate{Name: "t", Scopes: scopes}, 0, 0) + if err != nil { + t.Fatal(err) + } + return tok.Token + } + owner := user("owner@example.com") + if err := srv.store.AddWorkspaceMember(ws.ID, owner.ID, "owner"); err != nil { + t.Fatal(err) + } + viewer := user("viewer@example.com") + if err := srv.store.AddWorkspaceMember(ws.ID, viewer.ID, "viewer"); err != nil { + t.Fatal(err) + } + restricted := user("restricted@example.com") + if err := srv.store.AddWorkspaceMember(ws.ID, restricted.ID, "editor"); err != nil { + t.Fatal(err) + } + if err := srv.store.SetMemberCollectionAccess(ws.ID, restricted.ID, "specific", []string{ideas.ID}); err != nil { + t.Fatal(err) + } + guest := user("guest@example.com") + if _, err := srv.store.CreateItemGrant(ws.ID, item.ID, guest.ID, "view", owner.ID); err != nil { + t.Fatal(err) + } + stranger := user("stranger@example.com") + + path := "/api/v1/workspaces/" + slug + "/items/" + item.Slug + "/needs/snooze" + dismiss, _ := json.Marshal(map[string]any{"kind": "dismiss"}) + snoozed := func(u *models.User) bool { + t.Helper() + sn, err := srv.store.GetNeedsSnooze(u.ID, item.ID, time.Now()) + if err != nil { + t.Fatal(err) + } + return sn != nil + } + + for _, tc := range []struct { + name string + u *models.User + want int + }{ + {"viewer", viewer, http.StatusOK}, + {"item-grant guest", guest, http.StatusOK}, + {"restricted member", restricted, http.StatusNotFound}, + {"stranger", stranger, http.StatusNotFound}, + } { + t.Run(tc.name, func(t *testing.T) { + tok := token(tc.u, "") + rr := bearerCall(t, srv, http.MethodPut, path, tok, dismiss) + if rr.Code != tc.want { + t.Fatalf("PUT: %d %s, want %d", rr.Code, rr.Body.String(), tc.want) + } + if got := snoozed(tc.u); got != (tc.want == http.StatusOK) { + t.Fatalf("snooze row after PUT %d: %v", rr.Code, got) + } + rr = bearerCall(t, srv, http.MethodDelete, path, tok, nil) + if rr.Code != tc.want { + t.Fatalf("DELETE: %d %s, want %d", rr.Code, rr.Body.String(), tc.want) + } + if snoozed(tc.u) { + t.Fatal("snooze row survived DELETE") + } + }) + } + + t.Run("read-scoped token", func(t *testing.T) { + rr := bearerCall(t, srv, http.MethodPut, path, token(viewer, `["read"]`), dismiss) + if rr.Code != http.StatusForbidden { + t.Fatalf("PUT with a read token: %d %s, want 403", rr.Code, rr.Body.String()) + } + if snoozed(viewer) { + t.Fatal("a read token wrote a snooze") + } + }) + + t.Run("bad defer", func(t *testing.T) { + tok := token(viewer, "") + for _, body := range []map[string]any{ + {"kind": "defer", "defer_until": "next week"}, + {"kind": "defer", "defer_until": time.Now().Add(-time.Hour).Format(time.RFC3339)}, + {"kind": "mute"}, + } { + b, _ := json.Marshal(body) + if rr := bearerCall(t, srv, http.MethodPut, path, tok, b); rr.Code != http.StatusBadRequest { + t.Errorf("PUT %v: %d %s, want 400", body, rr.Code, rr.Body.String()) + } + } + if snoozed(viewer) { + t.Fatal("a refused PUT wrote a snooze") + } + until := time.Now().Add(72 * time.Hour).UTC().Truncate(time.Second) + b, _ := json.Marshal(map[string]any{"kind": "defer", "defer_until": until.Format(time.RFC3339)}) + rr := bearerCall(t, srv, http.MethodPut, path, tok, b) + if rr.Code != http.StatusOK { + t.Fatalf("PUT defer: %d %s", rr.Code, rr.Body.String()) + } + var resp struct { + Ref string `json:"ref"` + Snooze models.NeedsSnooze `json:"snooze"` + } + parseJSON(t, rr, &resp) + if resp.Ref != item.Ref || resp.Snooze.Kind != "defer" || resp.Snooze.DeferUntil != until.Format(time.RFC3339) || !resp.Snooze.Holds { + t.Fatalf("PUT defer response: %+v", resp) + } + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index 38416d156..592c9b929 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2326,6 +2326,10 @@ func (s *Server) setupRouter() { // pipeline. `pad watch ` / `pad watch remove `. r.Post("/watch", s.handleCreateWatch) r.Delete("/watch", s.handleDeleteWatch) + // Needs-you snooze (TASK-3918): the caller's own + // dismiss/defer, so viewers and guests may too. + r.Put("/needs/snooze", s.handleSnoozeNeedsItem) + r.Delete("/needs/snooze", s.handleUnsnoozeNeedsItem) // Push (IDEA-2544 Phase 1): transient, self-addressed // human→harness dispatch over the SAME watch-events // bus/stream — no durable row, see handlePushToItem's diff --git a/internal/store/migrated.go b/internal/store/migrated.go index aff55ff55..9f643efd1 100644 --- a/internal/store/migrated.go +++ b/internal/store/migrated.go @@ -142,6 +142,9 @@ var migratedRefusalTables = []string{ "attachments", "item_stars", "watches", + // A person's needs-you dismissals and deferrals (TASK-3918), the same + // class as item_stars: per-user workspace state the bundle does not carry. + "item_needs_snoozes", "comment_reactions", "views", "agent_roles", diff --git a/internal/store/migrated_test.go b/internal/store/migrated_test.go index 7725f195a..ff6e9f0dd 100644 --- a/internal/store/migrated_test.go +++ b/internal/store/migrated_test.go @@ -601,3 +601,48 @@ func TestMigratedRemedyReadsTheMarkerIndependently(t *testing.T) { t.Errorf("the remedy does not name the destination: %q", committed) } } + +// TestMigratedDatabaseRefusesNeedsSnoozeWrites names item_needs_snoozes +// (TASK-3918) through the store's own write path. The per-verb and +// structural legs above iterate MigratedRefusalTables itself, so a table +// dropped from the list drops out of what they check too; this leg does not +// read the list, so dropping the entry turns it red. +func TestMigratedDatabaseRefusesNeedsSnoozeWrites(t *testing.T) { + path := filepath.Join(t.TempDir(), "marked.db") + s, err := New(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer s.Close() + ws := createTestWorkspace(t, s, "Marked") + col := createTestCollection(t, s, ws.ID, "Tasks") + item := createTestItem(t, s, ws.ID, col.ID, "Task A", "") + u, err := s.CreateUser(models.UserCreate{Email: "snoozer@test.com", Name: "S", Password: "correct-horse-battery-staple"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + if _, err := s.SnoozeNeedsItem(u.ID, ws.ID, item.ID, models.NeedsSnoozeDismiss, time.Time{}, nil); err != nil { + t.Fatalf("seed snooze: %v", err) + } + + tx, err := s.BeginSnapshot() + if err != nil { + t.Fatalf("begin: %v", err) + } + if err := s.MarkMigratedTx(tx, "postgres://pg.example/pad"); err != nil { + t.Fatalf("MarkMigratedTx: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + + if _, err := s.SnoozeNeedsItem(u.ID, ws.ID, item.ID, models.NeedsSnoozeDefer, time.Now().Add(time.Hour), nil); err == nil || !strings.Contains(err.Error(), MigratedMarker) { + t.Errorf("a snooze write on a migrated database: err = %v, want the migration guard", err) + } + if _, err := s.UnsnoozeNeedsItem(u.ID, item.ID); err == nil || !strings.Contains(err.Error(), MigratedMarker) { + t.Errorf("an unsnooze on a migrated database: err = %v, want the migration guard", err) + } + if sn, err := s.GetNeedsSnooze(u.ID, item.ID, time.Now()); err != nil || sn == nil || sn.Kind != models.NeedsSnoozeDismiss { + t.Errorf("the seeded snooze did not survive the refused writes: %+v, %v", sn, err) + } +} diff --git a/internal/store/migrations/153_item_needs_snoozes.sql b/internal/store/migrations/153_item_needs_snoozes.sql new file mode 100644 index 000000000..ee9e77ab6 --- /dev/null +++ b/internal/store/migrations/153_item_needs_snoozes.sql @@ -0,0 +1,32 @@ +-- Migration 153: item_needs_snoozes (TASK-3918, IDEA-3906 half A unit A2). +-- +-- A person's dismissal or deferral of an item in their needs-you inbox. +-- Per user: one row per (user, item), and nobody else's view changes. +-- +-- kind 'dismiss' holds while nothing has happened since the snapshot: no +-- live comment by anyone but the user's own human comments is newer than +-- seen_comment_at, none at that same second has an id outside +-- seen_comment_ids, and the item's MAX(status_transitions.seq) is at most +-- seen_transition_seq. items.seq and updated_at cannot stand in: comments +-- move neither, and timestamps are whole seconds. +-- +-- kind 'defer' holds until defer_until, whatever changes before then +-- (IDEA-3906 A2, ruled 10-18). +-- +-- Every TEXT value is written by the server: kind is validated, timestamps +-- are re-formatted from parsed times, and seen_comment_ids holds only ids of +-- the item's own live comments (the client's marker is clamped to them). +CREATE TABLE IF NOT EXISTS item_needs_snoozes ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('dismiss', 'defer')), + defer_until TEXT, + seen_comment_at TEXT NOT NULL DEFAULT '', + seen_comment_ids TEXT NOT NULL DEFAULT '[]', + seen_transition_seq INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, item_id) +); + +CREATE INDEX IF NOT EXISTS idx_item_needs_snoozes_item ON item_needs_snoozes(item_id); diff --git a/internal/store/needs_snooze.go b/internal/store/needs_snooze.go new file mode 100644 index 000000000..ab365266d --- /dev/null +++ b/internal/store/needs_snooze.go @@ -0,0 +1,240 @@ +package store + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// Needs-you snoozes (TASK-3918, IDEA-3906 half A unit A2): one person's +// dismissal or deferral of one item. See migration 153 for the hold rules. + +// MaxNeedsDefer is how far ahead a deferral may be set. +const MaxNeedsDefer = 366 * 24 * time.Hour + +// ErrInvalidNeedsSnooze reports a snooze request the store refuses: an +// unknown kind, or a deferral that is not after now and within +// MaxNeedsDefer. The handler answers it with 400. +var ErrInvalidNeedsSnooze = errors.New("invalid needs snooze") + +// needsOthersComment is the comment predicate a dismissal watches: a live +// comment that is not the snoozing user's own human comment. The user's +// agent-authored comments count, as do comments with no user (legacy, +// apps). Imported comments do not: an import always creates new items, so +// they are history no snooze can predate, and their created_at is the +// bundle's text verbatim (export.go), which may not sort with now()'s +// format (#2231 r1). Bind the user id. +const needsOthersComment = `deleted_at IS NULL AND imported = 0 AND NOT (COALESCE(user_id, '') = ? AND created_by = 'user')` + +// needsItemState is the item's state as the snoozing user would see it now: +// the newest other comment's second, the ids at that second, and the latest +// status-transition seq (legacy rows are 0). +func (s *Store) needsItemState(q Queryer, userID, itemID string) (models.NeedsSnoozeMarker, error) { + var st models.NeedsSnoozeMarker + var at sql.NullString + if err := q.QueryRow(s.q(`SELECT MAX(created_at) FROM comments WHERE item_id = ? AND `+needsOthersComment), + itemID, userID).Scan(&at); err != nil { + return st, fmt.Errorf("read latest comment: %w", err) + } + st.SeenCommentAt = at.String + ids, err := s.needsCommentIDsAt(q, userID, itemID, st.SeenCommentAt) + if err != nil { + return st, err + } + st.SeenCommentIDs = ids + if err := q.QueryRow(s.q(`SELECT COALESCE(MAX(seq), 0) FROM status_transitions WHERE item_id = ?`), + itemID).Scan(&st.SeenTransitionSeq); err != nil { + return st, fmt.Errorf("read latest transition: %w", err) + } + return st, nil +} + +// needsCommentIDsAt lists the other comments created at exactly `at`. +func (s *Store) needsCommentIDsAt(q Queryer, userID, itemID, at string) ([]string, error) { + ids := []string{} + if at == "" { + return ids, nil + } + rows, err := q.Query(s.q(`SELECT id FROM comments WHERE item_id = ? AND created_at = ? AND `+needsOthersComment+` ORDER BY id`), + itemID, at, userID) + if err != nil { + return nil, fmt.Errorf("read comments at %s: %w", at, err) + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +// clampNeedsMarker brings a client's marker within the server's state, so it +// can only claim less was seen: the second is no later than the newest other +// comment (an unparseable one is "", seen nothing), the ids are the other +// comments that really exist at that second, and the seq is no higher than +// the item's. +func (s *Store) clampNeedsMarker(q Queryer, userID, itemID string, m models.NeedsSnoozeMarker, cur models.NeedsSnoozeMarker) (models.NeedsSnoozeMarker, error) { + var out models.NeedsSnoozeMarker + if t, err := time.Parse(time.RFC3339, m.SeenCommentAt); err == nil { + out.SeenCommentAt = t.UTC().Format(time.RFC3339) + } + if out.SeenCommentAt > cur.SeenCommentAt { + out.SeenCommentAt = cur.SeenCommentAt + } + real, err := s.needsCommentIDsAt(q, userID, itemID, out.SeenCommentAt) + if err != nil { + return out, err + } + claimed := make(map[string]bool, len(m.SeenCommentIDs)) + for _, id := range m.SeenCommentIDs { + claimed[id] = true + } + out.SeenCommentIDs = []string{} + for _, id := range real { + if claimed[id] { + out.SeenCommentIDs = append(out.SeenCommentIDs, id) + } + } + out.SeenTransitionSeq = min(max(m.SeenTransitionSeq, 0), cur.SeenTransitionSeq) + return out, nil +} + +// SnoozeNeedsItem records userID's dismissal or deferral of an item, +// replacing any earlier one. kind is models.NeedsSnoozeDismiss or +// models.NeedsSnoozeDefer; deferUntil is read only for a deferral. marker is +// what the client had seen (nil: the item's current state) and is clamped +// to the server's values. The caller has already checked the user may see +// the item. +func (s *Store) SnoozeNeedsItem(userID, workspaceID, itemID, kind string, deferUntil time.Time, marker *models.NeedsSnoozeMarker) (*models.NeedsSnooze, error) { + nowT := time.Now().UTC() + var until sql.NullString + switch kind { + case models.NeedsSnoozeDismiss: + case models.NeedsSnoozeDefer: + if !deferUntil.After(nowT) || deferUntil.After(nowT.Add(MaxNeedsDefer)) { + return nil, fmt.Errorf("%w: defer_until must be after now and within 366 days", ErrInvalidNeedsSnooze) + } + // Stored in whole seconds, rounded UP so a fractional time never + // brings the item back early (#2231 r1). + if t := deferUntil.Truncate(time.Second); t.Before(deferUntil) { + deferUntil = t.Add(time.Second) + } + until = sql.NullString{String: deferUntil.UTC().Format(time.RFC3339), Valid: true} + default: + return nil, fmt.Errorf("%w: kind must be %q or %q", ErrInvalidNeedsSnooze, models.NeedsSnoozeDismiss, models.NeedsSnoozeDefer) + } + + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + seen, err := s.needsItemState(tx, userID, itemID) + if err != nil { + return nil, err + } + if marker != nil { + if seen, err = s.clampNeedsMarker(tx, userID, itemID, *marker, seen); err != nil { + return nil, err + } + } + ids, err := json.Marshal(seen.SeenCommentIDs) + if err != nil { + return nil, err + } + created := nowT.Format(time.RFC3339) + if _, err := tx.Exec(s.q(` + INSERT INTO item_needs_snoozes + (user_id, item_id, workspace_id, kind, defer_until, seen_comment_at, seen_comment_ids, seen_transition_seq, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, item_id) DO UPDATE SET + kind = excluded.kind, + defer_until = excluded.defer_until, + seen_comment_at = excluded.seen_comment_at, + seen_comment_ids = excluded.seen_comment_ids, + seen_transition_seq = excluded.seen_transition_seq, + created_at = excluded.created_at`), + userID, itemID, workspaceID, kind, until, seen.SeenCommentAt, string(ids), seen.SeenTransitionSeq, created); err != nil { + return nil, fmt.Errorf("snooze needs item: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.GetNeedsSnooze(userID, itemID, nowT) +} + +// UnsnoozeNeedsItem removes userID's snooze on an item. It reports whether +// there was one. +func (s *Store) UnsnoozeNeedsItem(userID, itemID string) (bool, error) { + res, err := s.db.Exec(s.q(`DELETE FROM item_needs_snoozes WHERE user_id = ? AND item_id = ?`), userID, itemID) + if err != nil { + return false, fmt.Errorf("unsnooze needs item: %w", err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// GetNeedsSnooze returns userID's snooze on an item with Holds evaluated at +// now, or nil when there is none. A deferral holds until defer_until, whatever +// happens before then (IDEA-3906 A2). A dismissal holds until the item's +// state passes the marker: a newer other comment, another comment at the +// marker's second, or a later status transition. +func (s *Store) GetNeedsSnooze(userID, itemID string, now time.Time) (*models.NeedsSnooze, error) { + var sn models.NeedsSnooze + var until sql.NullString + var ids string + err := s.db.QueryRow(s.q(` + SELECT item_id, workspace_id, kind, defer_until, seen_comment_at, seen_comment_ids, seen_transition_seq, created_at + FROM item_needs_snoozes WHERE user_id = ? AND item_id = ?`), userID, itemID). + Scan(&sn.ItemID, &sn.WorkspaceID, &sn.Kind, &until, &sn.Marker.SeenCommentAt, &ids, &sn.Marker.SeenTransitionSeq, &sn.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get needs snooze: %w", err) + } + sn.DeferUntil = until.String + if err := json.Unmarshal([]byte(ids), &sn.Marker.SeenCommentIDs); err != nil { + return nil, fmt.Errorf("needs snooze seen_comment_ids: %w", err) + } + if sn.Kind == models.NeedsSnoozeDefer { + t, err := time.Parse(time.RFC3339, sn.DeferUntil) + sn.Holds = err == nil && now.Before(t) + return &sn, nil + } + cur, err := s.needsItemState(s.db, userID, itemID) + if err != nil { + return nil, err + } + sn.Holds = needsDismissHolds(sn.Marker, cur) + return &sn, nil +} + +// needsDismissHolds compares a dismissal's marker with the item's current +// state. A comment deleted since the marker leaves the newest second +// earlier, which never breaks the hold. +func needsDismissHolds(seen, cur models.NeedsSnoozeMarker) bool { + if cur.SeenTransitionSeq > seen.SeenTransitionSeq || cur.SeenCommentAt > seen.SeenCommentAt { + return false + } + if cur.SeenCommentAt < seen.SeenCommentAt { + return true + } + had := make(map[string]bool, len(seen.SeenCommentIDs)) + for _, id := range seen.SeenCommentIDs { + had[id] = true + } + for _, id := range cur.SeenCommentIDs { + if !had[id] { + return false + } + } + return true +} diff --git a/internal/store/needs_snooze_test.go b/internal/store/needs_snooze_test.go new file mode 100644 index 000000000..d4ebc450d --- /dev/null +++ b/internal/store/needs_snooze_test.go @@ -0,0 +1,318 @@ +package store + +import ( + "encoding/json" + "errors" + "testing" + "time" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// Needs-you snoozes (TASK-3918). Runs on both backends via testStore +// (PAD_TEST_POSTGRES_URL selects Postgres). + +type needsFixture struct { + s *Store + ws *models.Workspace + item *models.Item + me *models.User + other *models.User +} + +func newNeedsFixture(t *testing.T) needsFixture { + t.Helper() + s := testStore(t) + ws := createTestWorkspace(t, s, "Needs") + col := createTestCollection(t, s, ws.ID, "Tasks") + return needsFixture{ + s: s, ws: ws, + item: createTestItem(t, s, ws.ID, col.ID, "Asked", ""), + me: needsUser(t, s, "me@test.com"), + other: needsUser(t, s, "other@test.com"), + } +} + +func needsUser(t *testing.T, s *Store, email string) *models.User { + t.Helper() + u, err := s.CreateUser(models.UserCreate{Email: email, Name: email, Password: "correct-horse-battery-staple"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + return u +} + +// comment adds a comment by u (nil: no user) with createdBy, stamped at `at` +// so same-second cases are exact rather than timing-dependent. +func (f needsFixture) comment(t *testing.T, u *models.User, createdBy string, at time.Time) *models.Comment { + t.Helper() + uid := "" + if u != nil { + uid = u.ID + } + c, err := f.s.CreateComment(f.ws.ID, f.item.ID, uid, models.CommentCreate{Body: "b", Author: "a", CreatedBy: createdBy, Source: "web"}) + if err != nil { + t.Fatalf("create comment: %v", err) + } + if _, err := f.s.db.Exec(f.s.q(`UPDATE comments SET created_at = ? WHERE id = ?`), at.UTC().Format(time.RFC3339), c.ID); err != nil { + t.Fatal(err) + } + return c +} + +func (f needsFixture) setStatus(t *testing.T, status string) { + t.Helper() + fields := `{"status":"` + status + `"}` + if _, err := f.s.UpdateItem(f.item.ID, models.ItemUpdate{Fields: &fields}); err != nil { + t.Fatal(err) + } +} + +func (f needsFixture) snooze(t *testing.T, u *models.User, kind string, until time.Time, m *models.NeedsSnoozeMarker) *models.NeedsSnooze { + t.Helper() + sn, err := f.s.SnoozeNeedsItem(u.ID, f.ws.ID, f.item.ID, kind, until, m) + if err != nil { + t.Fatalf("snooze: %v", err) + } + return sn +} + +func (f needsFixture) holds(t *testing.T, u *models.User, at time.Time) bool { + t.Helper() + sn, err := f.s.GetNeedsSnooze(u.ID, f.item.ID, at) + if err != nil { + t.Fatal(err) + } + if sn == nil { + t.Fatal("no snooze row") + } + return sn.Holds +} + +var needsT0 = time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + +func TestNeedsSnooze_NewCommentResurfaces(t *testing.T) { + f := newNeedsFixture(t) + f.comment(t, f.other, "user", needsT0) + f.snooze(t, f.me, models.NeedsSnoozeDismiss, time.Time{}, nil) + if !f.holds(t, f.me, time.Now()) { + t.Fatal("a fresh dismissal does not hold") + } + + // My own human comment never re-surfaces it. + f.comment(t, f.me, "user", needsT0.Add(time.Minute)) + if !f.holds(t, f.me, time.Now()) { + t.Fatal("my own human comment broke my dismissal") + } + // A deleted comment by someone else does not either. It has a reply + // (mine), so the delete leaves a TOMBSTONE row with deleted_at set + // rather than removing the row. + gone := f.comment(t, f.other, "user", needsT0.Add(2*time.Minute)) + reply, err := f.s.CreateComment(f.ws.ID, f.item.ID, f.me.ID, models.CommentCreate{Body: "r", Author: "a", CreatedBy: "user", Source: "web", ParentID: gone.ID}) + if err != nil { + t.Fatal(err) + } + if err := f.s.DeleteComment(gone.ID); err != nil { + t.Fatal(err) + } + var tomb int + if err := f.s.db.QueryRow(f.s.q(`SELECT COUNT(*) FROM comments WHERE id = ? AND deleted_at IS NOT NULL`), gone.ID).Scan(&tomb); err != nil || tomb != 1 { + t.Fatalf("precondition: the deleted comment is a tombstone (%d, %v; reply %s)", tomb, err, reply.ID) + } + if !f.holds(t, f.me, time.Now()) { + t.Fatal("a deleted comment broke the dismissal") + } + // An imported comment is history, whatever its created_at says: this one + // carries a foreign offset form that sorts after every native stamp. + imp := f.comment(t, f.other, "user", needsT0) + if _, err := f.s.db.Exec(f.s.q(`UPDATE comments SET imported = 1, created_at = ? WHERE id = ?`), "2026-12-01T00:00:00+02:00", imp.ID); err != nil { + t.Fatal(err) + } + if !f.holds(t, f.me, time.Now()) { + t.Fatal("an imported comment broke the dismissal") + } + // An agent writing under my credentials is news. + f.comment(t, f.me, "agent", needsT0.Add(3*time.Minute)) + if f.holds(t, f.me, time.Now()) { + t.Fatal("an agent comment under my credentials did not re-surface the item") + } + + // Someone else's newer comment re-surfaces it (fresh dismissal first). + f.snooze(t, f.me, models.NeedsSnoozeDismiss, time.Time{}, nil) + if !f.holds(t, f.me, time.Now()) { + t.Fatal("re-dismissal does not hold") + } + f.comment(t, f.other, "user", needsT0.Add(4*time.Minute)) + if f.holds(t, f.me, time.Now()) { + t.Fatal("another person's newer comment did not re-surface the item") + } +} + +func TestNeedsSnooze_SameSecondBoundary(t *testing.T) { + f := newNeedsFixture(t) + seen := f.comment(t, f.other, "user", needsT0) + sn := f.snooze(t, f.me, models.NeedsSnoozeDismiss, time.Time{}, nil) + if sn.Marker.SeenCommentAt != needsT0.Format(time.RFC3339) || len(sn.Marker.SeenCommentIDs) != 1 || sn.Marker.SeenCommentIDs[0] != seen.ID { + t.Fatalf("marker = %+v, want the one comment at %s", sn.Marker, needsT0.Format(time.RFC3339)) + } + // A second comment in the SAME second: not newer, but not seen. + f.comment(t, f.other, "user", needsT0) + if f.holds(t, f.me, time.Now()) { + t.Fatal("a same-second comment outside seen_comment_ids did not re-surface the item") + } + // Control: dismissing now covers both, and it holds. + f.snooze(t, f.me, models.NeedsSnoozeDismiss, time.Time{}, nil) + if !f.holds(t, f.me, time.Now()) { + t.Fatal("a dismissal that saw both same-second comments does not hold") + } +} + +func TestNeedsSnooze_StatusRoundTripResurfaces(t *testing.T) { + f := newNeedsFixture(t) + f.snooze(t, f.me, models.NeedsSnoozeDismiss, time.Time{}, nil) + f.setStatus(t, "done") + f.setStatus(t, "open") + var status string + if err := f.s.db.QueryRow(f.s.q(`SELECT fields FROM items WHERE id = ?`), f.item.ID).Scan(&status); err != nil { + t.Fatal(err) + } + // Parsed, not compared as text: Postgres JSONB re-serialises the spacing. + var got map[string]any + if err := json.Unmarshal([]byte(status), &got); err != nil || got["status"] != "open" { + t.Fatalf("precondition: the round trip ends where it began, got %s (%v)", status, err) + } + if f.holds(t, f.me, time.Now()) { + t.Fatal("a status round trip (open -> done -> open) did not re-surface the item") + } +} + +func TestNeedsSnooze_DeferUntilAndChangeBeforeDate(t *testing.T) { + f := newNeedsFixture(t) + until := time.Now().Add(48 * time.Hour).Truncate(time.Second) + sn := f.snooze(t, f.me, models.NeedsSnoozeDefer, until, nil) + if sn.DeferUntil != until.UTC().Format(time.RFC3339) { + t.Fatalf("defer_until = %q, want %q", sn.DeferUntil, until.UTC().Format(time.RFC3339)) + } + // A change before the date does not bring it back (IDEA-3906 A2). + f.comment(t, f.other, "user", time.Now()) + f.setStatus(t, "done") + if !f.holds(t, f.me, time.Now()) { + t.Fatal("a change before the date brought a deferred item back") + } + if !f.holds(t, f.me, until.Add(-time.Second)) { + t.Fatal("the deferral lapsed before its date") + } + if f.holds(t, f.me, until) { + t.Fatal("the deferral still holds at its date") + } + + // A fractional time is stored rounded UP, never early. + frac := time.Now().Add(48 * time.Hour).Truncate(time.Second).Add(900 * time.Millisecond) + if sn := f.snooze(t, f.me, models.NeedsSnoozeDefer, frac, nil); sn.DeferUntil != frac.Truncate(time.Second).Add(time.Second).UTC().Format(time.RFC3339) { + t.Fatalf("fractional defer_until stored as %q, want the next whole second", sn.DeferUntil) + } + + for name, at := range map[string]time.Time{ + "now": time.Now(), + "past": time.Now().Add(-time.Hour), + "beyond a year": time.Now().Add(MaxNeedsDefer + time.Hour), + } { + if _, err := f.s.SnoozeNeedsItem(f.me.ID, f.ws.ID, f.item.ID, models.NeedsSnoozeDefer, at, nil); !errors.Is(err, ErrInvalidNeedsSnooze) { + t.Errorf("defer until %s: err = %v, want ErrInvalidNeedsSnooze", name, err) + } + } + if _, err := f.s.SnoozeNeedsItem(f.me.ID, f.ws.ID, f.item.ID, "mute", time.Time{}, nil); !errors.Is(err, ErrInvalidNeedsSnooze) { + t.Errorf("unknown kind: err = %v, want ErrInvalidNeedsSnooze", err) + } +} + +func TestNeedsSnooze_MarkerClamped(t *testing.T) { + f := newNeedsFixture(t) + old := f.comment(t, f.other, "user", needsT0) + f.setStatus(t, "done") + cur, err := f.s.needsItemState(f.s.db, f.me.ID, f.item.ID) + if err != nil { + t.Fatal(err) + } + + // A forged marker claiming the future, a bigger seq and an unknown id is + // clamped to what the server has. + sn := f.snooze(t, f.me, models.NeedsSnoozeDismiss, time.Time{}, &models.NeedsSnoozeMarker{ + SeenCommentAt: needsT0.Add(24 * time.Hour).Format(time.RFC3339), + SeenCommentIDs: []string{old.ID, "forged-id"}, + SeenTransitionSeq: cur.SeenTransitionSeq + 1000, + }) + if sn.Marker.SeenCommentAt != needsT0.Format(time.RFC3339) || + len(sn.Marker.SeenCommentIDs) != 1 || sn.Marker.SeenCommentIDs[0] != old.ID || + sn.Marker.SeenTransitionSeq != cur.SeenTransitionSeq { + t.Fatalf("forged marker stored as %+v, want clamped to %+v", sn.Marker, cur) + } + // So the next comment still re-surfaces the item. + f.comment(t, f.other, "user", needsT0.Add(time.Hour)) + if f.holds(t, f.me, time.Now()) { + t.Fatal("a forged future marker suppressed a real later comment") + } + + // An OLDER marker is kept: the client saw less, so what it missed counts. + sn = f.snooze(t, f.me, models.NeedsSnoozeDismiss, time.Time{}, &models.NeedsSnoozeMarker{ + SeenCommentAt: needsT0.Format(time.RFC3339), + SeenCommentIDs: []string{old.ID}, + SeenTransitionSeq: cur.SeenTransitionSeq, + }) + if sn.Marker.SeenCommentAt != needsT0.Format(time.RFC3339) || sn.Holds { + t.Fatalf("older marker: stored %+v holds=%v, want kept and not holding", sn.Marker, sn.Holds) + } + // The ids are filtered to the ones claimed: a marker that saw only one + // of two same-second comments stores that one, and does not hold. + b1 := f.comment(t, f.other, "user", needsT0.Add(2*time.Hour)) + f.comment(t, f.other, "user", needsT0.Add(2*time.Hour)) + cur, err = f.s.needsItemState(f.s.db, f.me.ID, f.item.ID) + if err != nil { + t.Fatal(err) + } + sn = f.snooze(t, f.me, models.NeedsSnoozeDismiss, time.Time{}, &models.NeedsSnoozeMarker{ + SeenCommentAt: needsT0.Add(2 * time.Hour).Format(time.RFC3339), + SeenCommentIDs: []string{b1.ID}, + SeenTransitionSeq: cur.SeenTransitionSeq, + }) + if len(sn.Marker.SeenCommentIDs) != 1 || sn.Marker.SeenCommentIDs[0] != b1.ID || sn.Holds { + t.Fatalf("partial same-second marker: stored %+v holds=%v, want only %s and not holding", sn.Marker, sn.Holds, b1.ID) + } + // An unparseable second means nothing was seen. + sn = f.snooze(t, f.me, models.NeedsSnoozeDismiss, time.Time{}, &models.NeedsSnoozeMarker{SeenCommentAt: "yesterday"}) + if sn.Marker.SeenCommentAt != "" || sn.Holds { + t.Fatalf("unparseable marker: stored %+v holds=%v, want empty and not holding", sn.Marker, sn.Holds) + } +} + +func TestNeedsSnooze_PerUser(t *testing.T) { + f := newNeedsFixture(t) + f.snooze(t, f.me, models.NeedsSnoozeDismiss, time.Time{}, nil) + if sn, err := f.s.GetNeedsSnooze(f.other.ID, f.item.ID, time.Now()); err != nil || sn != nil { + t.Fatalf("another user sees my snooze: %+v, %v", sn, err) + } + // The other person's own comment breaks MY dismissal, and theirs would + // not break theirs: "own" is per user. + f.snooze(t, f.other, models.NeedsSnoozeDismiss, time.Time{}, nil) + f.comment(t, f.other, "user", needsT0) + if f.holds(t, f.me, time.Now()) { + t.Fatal("the other person's comment did not re-surface my dismissal") + } + if !f.holds(t, f.other, time.Now()) { + t.Fatal("the other person's own comment broke their dismissal") + } + + ok, err := f.s.UnsnoozeNeedsItem(f.me.ID, f.item.ID) + if err != nil || !ok { + t.Fatalf("unsnooze: %v, %v", ok, err) + } + if sn, _ := f.s.GetNeedsSnooze(f.me.ID, f.item.ID, time.Now()); sn != nil { + t.Fatal("my snooze survived unsnooze") + } + if sn, _ := f.s.GetNeedsSnooze(f.other.ID, f.item.ID, time.Now()); sn == nil { + t.Fatal("unsnoozing mine removed theirs") + } + if ok, err := f.s.UnsnoozeNeedsItem(f.me.ID, f.item.ID); err != nil || ok { + t.Fatalf("second unsnooze: %v, %v; want false, nil", ok, err) + } +} diff --git a/internal/store/nulcolumns.go b/internal/store/nulcolumns.go index 073bfcd4b..75165ab4b 100644 --- a/internal/store/nulcolumns.go +++ b/internal/store/nulcolumns.go @@ -423,6 +423,14 @@ var nulExcluded = map[string]string{ "workspace_read_cursors.user_id": "the authenticated user's server-minted UUID (TASK-3615, migration 149)", "workspace_read_cursors.workspace_id": "server-minted workspace UUID from the resolved request (TASK-3615)", "workspace_read_cursors.seen_at": "server timestamp from now() in the fixed-width decisionTime format (TASK-3615); never from a request", + "item_needs_snoozes.user_id": "the authenticated user's server-minted UUID (TASK-3918, migration 153)", + "item_needs_snoozes.item_id": "server-minted item UUID from the resolved, visibility-checked item (TASK-3918)", + "item_needs_snoozes.workspace_id": "server-minted workspace UUID from the resolved request (TASK-3918)", + "item_needs_snoozes.kind": "validated against the two-value enum before the write, and a CHECK constraint (TASK-3918)", + "item_needs_snoozes.defer_until": "re-formatted from a parsed RFC3339 time, never the request's text (TASK-3918)", + "item_needs_snoozes.seen_comment_at": "re-formatted from a parsed time and clamped to a stored comments.created_at (TASK-3918)", + "item_needs_snoozes.seen_comment_ids": "JSON array of comment UUIDs read back from the comments table; the client's ids only filter that set (TASK-3918)", + "item_needs_snoozes.created_at": "server timestamp from time.Now (TASK-3918)", "idempotency_keys.workspace_id": "server-minted workspace UUID from the resolved request (TASK-3585, migration 139)", "idempotency_keys.user_id": "the authenticated user's server-minted UUID, or empty before setup (TASK-3585)", "idempotency_keys.route": "fixed server constant naming the handler (TASK-3585); never from a request", diff --git a/internal/store/pgmigrations/122_item_needs_snoozes.sql b/internal/store/pgmigrations/122_item_needs_snoozes.sql new file mode 100644 index 000000000..a8492d5e8 --- /dev/null +++ b/internal/store/pgmigrations/122_item_needs_snoozes.sql @@ -0,0 +1,16 @@ +-- Migration 122 (Postgres): item_needs_snoozes (TASK-3918). +-- Postgres counterpart to migrations/153_item_needs_snoozes.sql; the rationale is there. +CREATE TABLE IF NOT EXISTS item_needs_snoozes ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('dismiss', 'defer')), + defer_until TEXT, + seen_comment_at TEXT NOT NULL DEFAULT '', + seen_comment_ids TEXT NOT NULL DEFAULT '[]', + seen_transition_seq INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, item_id) +); + +CREATE INDEX IF NOT EXISTS idx_item_needs_snoozes_item ON item_needs_snoozes(item_id); diff --git a/internal/store/workspace_purge_test.go b/internal/store/workspace_purge_test.go index 6c43b058a..30579341c 100644 --- a/internal/store/workspace_purge_test.go +++ b/internal/store/workspace_purge_test.go @@ -35,6 +35,9 @@ var wsChildTables = []string{ "workspace_invitations", "custom_templates", "api_tokens", "share_links", "oauth_connection_workspaces", "user_report_layouts", "member_collection_access", "workspace_members", "attachments", "activities", + // item_needs_snoozes (TASK-3918) has no line in purgeWorkspaceChildDeletes: + // its FKs to items and workspaces cascade. Listed so the purge tests prove it. + "item_needs_snoozes", // event_outbox has NO foreign key to workspaces (TASK-2658 — an outbox row // must outlive its subject), so nothing deletes it on the purge's behalf. // It holds full item content and comment bodies, so an omission here keeps @@ -99,6 +102,8 @@ func seedFullWorkspace(t *testing.T, s *Store, u *models.User, name string) seed newID(), ws.ID, item1.ID, item2.ID, ts) s.mustExec(t, `INSERT INTO item_stars (user_id, item_id, created_at) VALUES (?, ?, ?)`, u.ID, item1.ID, ts) + s.mustExec(t, `INSERT INTO item_needs_snoozes (user_id, item_id, workspace_id, kind, created_at) VALUES (?, ?, ?, ?, ?)`, + u.ID, item1.ID, ws.ID, "dismiss", ts) s.mustExec(t, `INSERT INTO item_yjs_updates (item_id, update_data, schema_version, created_at) VALUES (?, ?, ?, ?)`, item1.ID, []byte{1, 2, 3}, "v1", ts) s.mustExec(t, `INSERT INTO item_wiki_links (source_item_id, target_kind, position) VALUES (?, ?, ?)`,