Files
pad/internal/store/needs_read.go
T
xarmian dd412a022d feat(needs): GET /me/needs, the cross-workspace needs-you inbox and its router (TASK-3920) (#2253)
* feat(store): reads behind the cross-workspace needs inbox (TASK-3920)

GuestWorkspaceIDs and GetWorkspacesByIDs for the fan-out; NeedsCandidateItems
(visibility scope in SQL), OpenMentionsForItems, NeedsSnoozesForItems and
NeedsMarkersForItems as batches, WorkspaceOwnerIDs, LatestAssignmentTimes.
Snoozes are joined through items.workspace_id and read only for ids the caller
already scoped by visibility (day-19 rulings on TASK-3920).

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* feat(needs): the routing rule, row types and cursor for the needs inbox (TASK-3920)

Resolve applies asked, mentioned, assigned, admin_fallback in strict exclusive
order over pure inputs (TestResolve_Order). WaitingSince is the earliest open
claim. Score.Value is an int 0-100; Scorer takes the viewer and scope. The
cursor carries as_of, view and ws and is position only.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* feat(server): GET /me/needs, the cross-workspace needs-you inbox (TASK-3920)

Fans out over the caller's member and guest workspaces (token allow-list, and a
token bound to one workspace stays in it), reads candidates under the caller's
visibility, routes them with needs.Resolve, applies the caller's snoozes
(joined through items.workspace_id), and pages a total order. degraded is set
only on a provider error. Documented in docs/api.md.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* fix(server): classify the needs inbox's route and lifecycle call (TASK-3920)

Adds GET /me/needs to the compress census and its IsTerminalItem call to the
PLAN-3535 lifecycle classification, and drops an unused helper and test field
the linter found. Follows the previous commit; both ride the same PR.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* fix(needs): a mentioned row is built from the caller's own mentions; attention flags read only caller-visible items (TASK-3920)

The mentioned rule built one routing for every mentioned person, so a
caller's row carried the earliest mention of anyone and other people's
commenters and comment ids. Resolve now builds each person's view from
their own mentions (Routing.For), and the handler takes the caller's.

The attention read evaluated the whole workspace before visibility, so an
error from an item the caller cannot see set degraded. A new
Runner.WorkspaceFlagsForItems filters decision rows to the given ids
before any state read; the handler passes the caller-visible ones.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* fix(needs): only surfaced needs_human rows set a claim; clamp a forged future as_of (TASK-3920)

The needs_human claim time was filled from every latest decision row, so a
candidate whose row did not surface (below threshold, a no, stale) got a false
needs_human reason whenever the workspace had any truly flagged item. Record
and apply it only for flagged items. A cursor as_of later than now is clamped
to now so the defer snapshot cannot be forged forward.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* test(needs): restricted-scope store test; document the display-name assignee match (TASK-3920)

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 15:23:54 -04:00

474 lines
16 KiB
Go

package store
import (
"database/sql"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// Store reads behind the cross-workspace needs-you router (TASK-3920,
// IDEA-3906 half A unit A4). Every read here takes ids the caller already
// scoped to what the viewer may see; none of them decides visibility.
// needsChunk bounds an IN list.
const needsChunk = 500
// GuestWorkspaceIDs lists the live workspaces where userID holds a collection
// or item grant but is NOT a member: the guest workspaces. A member's grants
// do not make a workspace a guest one, and a soft-deleted workspace never
// appears. The result is sorted by id. BUG-3914 reuses it for search.
func (s *Store) GuestWorkspaceIDs(userID string) ([]string, error) {
rows, err := s.db.Query(s.q(`
SELECT w.id FROM workspaces w
WHERE w.deleted_at IS NULL
AND (EXISTS (SELECT 1 FROM collection_grants cg WHERE cg.workspace_id = w.id AND cg.user_id = ?)
OR EXISTS (SELECT 1 FROM item_grants ig WHERE ig.workspace_id = w.id AND ig.user_id = ?))
AND NOT EXISTS (SELECT 1 FROM workspace_members wm WHERE wm.workspace_id = w.id AND wm.user_id = ?)
ORDER BY w.id`), userID, userID, userID)
if err != nil {
return nil, fmt.Errorf("guest workspace ids: %w", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// GetWorkspacesByIDs loads live workspaces by id with their owner's username,
// in one query per chunk. Unknown and soft-deleted ids are skipped.
func (s *Store) GetWorkspacesByIDs(ids []string) ([]models.Workspace, error) {
var out []models.Workspace
for _, chunk := range chunkStrings(ids, needsChunk) {
args := make([]any, len(chunk))
for i, id := range chunk {
args[i] = id
}
rows, err := s.db.Query(s.q(`
SELECT w.id, w.name, w.slug, COALESCE(w.owner_id, ''), COALESCE(ou.username, ''), w.description, w.settings, w.source, w.created_at, w.updated_at
FROM workspaces w LEFT JOIN users ou ON ou.id = w.owner_id
WHERE w.deleted_at IS NULL AND w.id IN (`+placeholders(len(chunk))+`)
ORDER BY w.id`), args...)
if err != nil {
return nil, fmt.Errorf("workspaces by ids: %w", err)
}
for rows.Next() {
var ws models.Workspace
var createdAt, updatedAt string
if err := rows.Scan(&ws.ID, &ws.Name, &ws.Slug, &ws.OwnerID, &ws.OwnerUsername, &ws.Description, &ws.Settings, &ws.Source, &createdAt, &updatedAt); err != nil {
rows.Close()
return nil, fmt.Errorf("scan workspace: %w", err)
}
ws.CreatedAt = parseTime(createdAt)
ws.UpdatedAt = parseTime(updatedAt)
ws.HydrateDerivedFields()
out = append(out, ws)
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
}
return out, nil
}
// WorkspaceOwnerIDs lists the workspace's human owners, ordered by id. Disabled
// accounts are included: eligibility belongs to the router.
func (s *Store) WorkspaceOwnerIDs(workspaceID string) ([]string, error) {
rows, err := s.db.Query(s.q(`
SELECT wm.user_id FROM workspace_members wm JOIN users u ON u.id = wm.user_id
WHERE wm.workspace_id = ? AND wm.role = 'owner' AND u.kind = 'human'
ORDER BY wm.user_id`), workspaceID)
if err != nil {
return nil, fmt.Errorf("workspace owner ids: %w", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// openMentionSQL is the predicate for an OPEN mention row m (comment_mentions)
// on item alias i: the comment is live, its author is not the mentioned user,
// and the mentioned user has written no live human comment on the item since
// (a reply at the same second counts as an answer: >=). It binds nothing.
const openMentionSQL = `
JOIN comments cm ON cm.id = m.comment_id AND cm.deleted_at IS NULL
WHERE NOT (COALESCE(cm.user_id, '') = m.user_id AND cm.created_by = 'user')
AND NOT EXISTS (
SELECT 1 FROM comments r
WHERE r.item_id = m.item_id AND r.deleted_at IS NULL AND r.created_by = 'user'
AND r.user_id = m.user_id AND r.created_at >= m.created_at)`
// NeedsCandidateParams selects the items the router considers in one workspace.
type NeedsCandidateParams struct {
// AwaitsCollectionIDs are the awaits_human collections: their items are
// candidates. FlaggedItemIDs are the needs_human items. An item with an
// open mention is a candidate in any non-system collection.
AwaitsCollectionIDs []string
FlaggedItemIDs []string
// ForUserID, when set, keeps only the items that user is the assignee of or
// holds an open mention on: the rows a non-owner caller can be routed.
ForUserID string
}
// NeedsCandidateItems returns the live items in scope that are router
// candidates: in a live, non-system collection, and an ask, a flagged item or
// holding an open mention. The scope clause is the caller's visibility and is
// applied in SQL, so nothing else is read for an item outside it. Status is not
// filtered here (it is per-collection JSON); the router drops terminal items.
// Only the columns a needs row shows are filled; Content is empty.
func (s *Store) NeedsCandidateItems(workspaceID string, scope CatchUpScope, p NeedsCandidateParams) ([]models.Item, error) {
if scope.seesNothing() {
return nil, nil
}
scopeSQL, scopeArgs := scope.clause("i")
args := []any{workspaceID}
args = append(args, scopeArgs...)
var src []string
for _, chunk := range chunkStrings(p.AwaitsCollectionIDs, needsChunk) {
src = append(src, "i.collection_id IN ("+placeholders(len(chunk))+")")
for _, id := range chunk {
args = append(args, id)
}
}
for _, chunk := range chunkStrings(p.FlaggedItemIDs, needsChunk) {
src = append(src, "i.id IN ("+placeholders(len(chunk))+")")
for _, id := range chunk {
args = append(args, id)
}
}
src = append(src, `EXISTS (SELECT 1 FROM comment_mentions m `+openMentionSQL+` AND m.item_id = i.id)`)
q := `
SELECT i.id, i.collection_id, i.title, i.slug, i.fields, i.assigned_user_id, COALESCE(au.name, ''),
i.item_number, i.created_at, i.updated_at, c.slug, c.name, c.prefix
FROM items i
JOIN collections c ON c.id = i.collection_id AND c.deleted_at IS NULL AND NOT c.is_system
LEFT JOIN users au ON au.id = i.assigned_user_id
WHERE i.workspace_id = ? AND i.deleted_at IS NULL` + scopeSQL + `
AND (` + strings.Join(src, " OR ") + `)`
if p.ForUserID != "" {
q += `
AND (i.assigned_user_id = ? OR EXISTS (SELECT 1 FROM comment_mentions m ` + openMentionSQL + ` AND m.item_id = i.id AND m.user_id = ?))`
args = append(args, p.ForUserID, p.ForUserID)
}
q += ` ORDER BY i.created_at, i.id`
rows, err := s.db.Query(s.q(q), args...)
if err != nil {
return nil, fmt.Errorf("needs candidate items: %w", err)
}
defer rows.Close()
var out []models.Item
for rows.Next() {
var it models.Item
var createdAt, updatedAt string
if err := rows.Scan(&it.ID, &it.CollectionID, &it.Title, &it.Slug, &it.Fields, &it.AssignedUserID, &it.AssignedUserName,
&it.ItemNumber, &createdAt, &updatedAt, &it.CollectionSlug, &it.CollectionName, &it.CollectionPrefix); err != nil {
return nil, fmt.Errorf("scan needs candidate: %w", err)
}
it.WorkspaceID = workspaceID
it.CreatedAt = parseTime(createdAt)
it.UpdatedAt = parseTime(updatedAt)
it.ComputeRef()
out = append(out, it)
}
return out, rows.Err()
}
// NeedsMention is one open @mention of a user on an item.
type NeedsMention struct {
UserID string
CommentID string
By string // the commenter's display name
At time.Time
}
// OpenMentionsForItems returns the open mentions on the given items (see
// openMentionSQL), per item ordered by mention time then user id.
func (s *Store) OpenMentionsForItems(itemIDs []string) (map[string][]NeedsMention, error) {
out := map[string][]NeedsMention{}
for _, chunk := range chunkStrings(itemIDs, needsChunk) {
args := make([]any, len(chunk))
for i, id := range chunk {
args[i] = id
}
rows, err := s.db.Query(s.q(`
SELECT m.item_id, m.user_id, m.comment_id, cm.author, m.created_at
FROM comment_mentions m `+openMentionSQL+`
AND m.item_id IN (`+placeholders(len(chunk))+`)
ORDER BY m.item_id, m.created_at, m.user_id`), args...)
if err != nil {
return nil, fmt.Errorf("open mentions: %w", err)
}
for rows.Next() {
var itemID, at string
var m NeedsMention
if err := rows.Scan(&itemID, &m.UserID, &m.CommentID, &m.By, &at); err != nil {
rows.Close()
return nil, err
}
m.At = parseTime(at)
out[itemID] = append(out[itemID], m)
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
}
return out, nil
}
// NeedsSnoozesForItems returns userID's snoozes on the given items of one
// workspace with Holds evaluated at now, keyed by item id. It is the batch of
// GetNeedsSnooze with two scoping rules (TASK-3920, the day-19 rulings):
//
// - the snooze is joined to its ITEM and scoped by items.workspace_id, never
// by item_needs_snoozes.workspace_id: a cross-workspace move clones the item
// and leaves the snooze on the archived source, which is not live here;
// - the caller passes ids it has already filtered by visibility, so a snooze
// on an item the user can no longer see is never read.
func (s *Store) NeedsSnoozesForItems(userID, workspaceID string, itemIDs []string, now time.Time) (map[string]*models.NeedsSnooze, error) {
out := map[string]*models.NeedsSnooze{}
var dismissed []string
for _, chunk := range chunkStrings(itemIDs, needsChunk) {
args := []any{userID, workspaceID}
for _, id := range chunk {
args = append(args, id)
}
rows, err := s.db.Query(s.q(`
SELECT s.item_id, s.kind, s.defer_until, s.seen_comment_at, s.seen_comment_ids, s.seen_transition_seq, s.created_at
FROM item_needs_snoozes s JOIN items i ON i.id = s.item_id
WHERE s.user_id = ? AND i.workspace_id = ? AND i.deleted_at IS NULL
AND s.item_id IN (`+placeholders(len(chunk))+`)`), args...)
if err != nil {
return nil, fmt.Errorf("needs snoozes: %w", err)
}
for rows.Next() {
sn := &models.NeedsSnooze{WorkspaceID: workspaceID}
var until sql.NullString
var ids string
if err := rows.Scan(&sn.ItemID, &sn.Kind, &until, &sn.Marker.SeenCommentAt, &ids, &sn.Marker.SeenTransitionSeq, &sn.CreatedAt); err != nil {
rows.Close()
return nil, err
}
sn.DeferUntil = until.String
if err := json.Unmarshal([]byte(ids), &sn.Marker.SeenCommentIDs); err != nil {
rows.Close()
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)
} else {
dismissed = append(dismissed, sn.ItemID)
}
out[sn.ItemID] = sn
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
}
if len(dismissed) == 0 {
return out, nil
}
cur, err := s.needsItemStates(userID, dismissed, out)
if err != nil {
return nil, err
}
for _, id := range dismissed {
out[id].Holds = needsDismissHolds(out[id].Marker, cur[id])
}
return out, nil
}
// needsItemStates is needsItemState for many items: the newest other comment's
// second, the other comments at the second the snooze's marker names (the only
// second whose ids matter; every item's newest second when snoozes is nil), and
// the latest transition seq.
func (s *Store) needsItemStates(userID string, itemIDs []string, snoozes map[string]*models.NeedsSnooze) (map[string]models.NeedsSnoozeMarker, error) {
cur := make(map[string]models.NeedsSnoozeMarker, len(itemIDs))
for _, id := range itemIDs {
cur[id] = models.NeedsSnoozeMarker{}
}
for _, chunk := range chunkStrings(itemIDs, needsChunk) {
args := []any{userID}
for _, id := range chunk {
args = append(args, id)
}
rows, err := s.db.Query(s.q(`SELECT item_id, MAX(created_at) FROM comments WHERE `+needsOthersComment+
` AND item_id IN (`+placeholders(len(chunk))+`) GROUP BY item_id`), args...)
if err != nil {
return nil, fmt.Errorf("read latest comments: %w", err)
}
for rows.Next() {
var id, at string
if err := rows.Scan(&id, &at); err != nil {
rows.Close()
return nil, err
}
m := cur[id]
m.SeenCommentAt = at
cur[id] = m
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
args = args[1:]
rows, err = s.db.Query(s.q(`SELECT item_id, COALESCE(MAX(seq), 0) FROM status_transitions WHERE item_id IN (`+
placeholders(len(chunk))+`) GROUP BY item_id`), args...)
if err != nil {
return nil, fmt.Errorf("read latest transitions: %w", err)
}
for rows.Next() {
var id string
var seq int64
if err := rows.Scan(&id, &seq); err != nil {
rows.Close()
return nil, err
}
m := cur[id]
m.SeenTransitionSeq = seq
cur[id] = m
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
}
// The ids at a second matter only where the item's newest second is the
// marker's second; fetch them for those (item, second) pairs together.
var pairs []string
for _, id := range itemIDs {
if at := cur[id].SeenCommentAt; at != "" && (snoozes == nil || at == snoozes[id].Marker.SeenCommentAt) {
pairs = append(pairs, id)
}
}
for _, chunk := range chunkStrings(pairs, needsChunk/2) {
args := []any{userID}
conds := make([]string, len(chunk))
for i, id := range chunk {
conds[i] = "(item_id = ? AND created_at = ?)"
args = append(args, id, cur[id].SeenCommentAt)
}
rows, err := s.db.Query(s.q(`SELECT item_id, id FROM comments WHERE `+needsOthersComment+
` AND (`+strings.Join(conds, " OR ")+`) ORDER BY item_id, id`), args...)
if err != nil {
return nil, fmt.Errorf("read comments at the marker second: %w", err)
}
for rows.Next() {
var itemID, id string
if err := rows.Scan(&itemID, &id); err != nil {
rows.Close()
return nil, err
}
m := cur[itemID]
m.SeenCommentIDs = append(m.SeenCommentIDs, id)
cur[itemID] = m
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
}
return cur, nil
}
// NeedsMarkersForItems returns each item's state as userID would see it now
// (the marker a snooze made at this moment would carry), for the rows a page
// shows. Every id gets an entry, with a non-nil SeenCommentIDs.
func (s *Store) NeedsMarkersForItems(userID string, itemIDs []string) (map[string]models.NeedsSnoozeMarker, error) {
cur, err := s.needsItemStates(userID, itemIDs, nil)
if err != nil {
return nil, err
}
for id, m := range cur {
if m.SeenCommentIDs == nil {
m.SeenCommentIDs = []string{}
cur[id] = m
}
}
return cur, nil
}
// LatestAssignmentTimes returns, per item, when it was last assigned to the
// named person: the newest "updated" activity whose change list holds
// "assigned: <before> → <name>". assigned maps item id to the assignee's
// display name. An item with no such row is absent (the caller falls back to
// the item's created_at: an item created already assigned has no row).
// The activity trail records the name, not the id, so two people sharing a
// display name can be confused; the worst effect is an age.
func (s *Store) LatestAssignmentTimes(workspaceID string, assigned map[string]string) (map[string]time.Time, error) {
ids := make([]string, 0, len(assigned))
for id := range assigned {
ids = append(ids, id)
}
sort.Strings(ids)
out := map[string]time.Time{}
for _, chunk := range chunkStrings(ids, needsChunk) {
args := []any{workspaceID}
for _, id := range chunk {
args = append(args, id)
}
rows, err := s.db.Query(s.q(`
SELECT a.document_id, `+s.activityMetaText("a")+`, a.created_at
FROM activities a
WHERE a.workspace_id = ? AND a.action = 'updated'
AND `+s.activityMetaText("a")+` LIKE '%assigned: %'
AND a.document_id IN (`+placeholders(len(chunk))+`)
ORDER BY a.created_at, a.id`), args...)
if err != nil {
return nil, fmt.Errorf("assignment activity: %w", err)
}
for rows.Next() {
var id, meta, at string
if err := rows.Scan(&id, &meta, &at); err != nil {
rows.Close()
return nil, err
}
var m map[string]any
if json.Unmarshal([]byte(meta), &m) != nil {
continue
}
changes, _ := m["changes"].(string)
for _, part := range strings.Split(changes, "; ") {
if strings.HasPrefix(part, "assigned: ") && strings.HasSuffix(part, " → "+assigned[id]) {
out[id] = parseTime(at) // rows ascend, so the last match is the newest
}
}
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
}
return out, nil
}