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
This commit is contained in:
xarmian
2026-10-19 07:02:00 -04:00
committed by GitHub
parent e432d0f5bc
commit eb7f8a200c
13 changed files with 962 additions and 0 deletions
+1
View File
@@ -82,6 +82,7 @@ REST API at `/api/v1/`. Key endpoints:
- `GET /workspaces/{ws}/changes?since=<unix_ms>` — 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=<next_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)
+31
View File
@@ -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"
)
+108
View File
@@ -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,
})
}
@@ -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)
}
})
}
+4
View File
@@ -2326,6 +2326,10 @@ func (s *Server) setupRouter() {
// pipeline. `pad watch <ref>` / `pad watch remove <ref>`.
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
+3
View File
@@ -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",
+45
View File
@@ -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)
}
}
@@ -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);
+240
View File
@@ -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
}
+318
View File
@@ -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)
}
}
+8
View File
@@ -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",
@@ -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);
+5
View File
@@ -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 (?, ?, ?)`,