feat(inbox): t-paliad-249 Slice A backend — project_event feed + read cursor (m/paliad#80)
Substrate changes that turn /inbox from approvals-only into the unified notification surface m asked for. - Migration 126: paliad.users.inbox_seen_at (high-watermark read cursor; pending approval_requests bypass it per design §3). - KnownProjectEventKinds gains note_created, our_side_changed, deadline_updated/deleted, deadlines_imported. New InboxProjectEventKinds curated subset (head's Q1=A lock). - InboxSystemView spans [approval_request, project_event]; defaults to past 30 days, newest first, row_action="inbox". - view_service.allowedProjectEventKinds drops *_approval_* audits when ApprovalRequest is also in spec.Sources (no double-count). - RunSpec resolves the caller's inbox_seen_at once and threads it through viewSpecBounds; runProjectEvents excludes self-authored events and rows older than the cursor when unread_only is set. Decided approval_requests follow the cursor; pending always survives. - ApprovalService.UnseenInboxCountForUser (unified badge count) + MarkInboxSeen + InboxSeenAt service methods. - GET /api/inbox/count returns the unified count; new POST /api/inbox/mark-all-seen advances the cursor (optional up_to=). Tests cover the InboxSystemView shape, the audit-dedup helper, the isApprovalAuditKind matcher, and the no-narrow-no-approvals nil path.
This commit is contained in:
4
internal/db/migrations/126_users_inbox_seen_at.down.sql
Normal file
4
internal/db/migrations/126_users_inbox_seen_at.down.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- t-paliad-249 — drop inbox read cursor.
|
||||||
|
|
||||||
|
ALTER TABLE paliad.users
|
||||||
|
DROP COLUMN IF EXISTS inbox_seen_at;
|
||||||
21
internal/db/migrations/126_users_inbox_seen_at.up.sql
Normal file
21
internal/db/migrations/126_users_inbox_seen_at.up.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- t-paliad-249 — /inbox overhaul, Slice A.
|
||||||
|
-- Add a per-user high-watermark read cursor for the inbox feed
|
||||||
|
-- (approval requests + curated project_events). The cursor advances
|
||||||
|
-- only when the user POSTs to /api/inbox/mark-all-seen. NULL means
|
||||||
|
-- "never visited" → every row counts as unread on first paint.
|
||||||
|
--
|
||||||
|
-- Note on the carve-out enforced in service code: pending
|
||||||
|
-- approval_requests count toward the inbox's unread state regardless
|
||||||
|
-- of this column. The cursor narrows the project_event source only,
|
||||||
|
-- so a stale cursor never buries a high-value pending approval.
|
||||||
|
--
|
||||||
|
-- Design ref: docs/design-inbox-overhaul-2026-05-25.md §3.
|
||||||
|
|
||||||
|
ALTER TABLE paliad.users
|
||||||
|
ADD COLUMN IF NOT EXISTS inbox_seen_at timestamptz NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN paliad.users.inbox_seen_at IS
|
||||||
|
'High-watermark cursor for the /inbox feed. project_events newer '
|
||||||
|
'than this timestamp are unread for the caller; NULL = never '
|
||||||
|
'visited (everything unread). Pending approval_requests bypass '
|
||||||
|
'this column and stay unread until decided.';
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
@@ -221,6 +222,16 @@ func handleListInboxMine(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/inbox/count — bell badge count for the sidebar.
|
// GET /api/inbox/count — bell badge count for the sidebar.
|
||||||
|
//
|
||||||
|
// Since t-paliad-249 (Slice A) the count is the **unified** unread
|
||||||
|
// count: pending approval requests (regardless of cursor) +
|
||||||
|
// curated project_events (InboxProjectEventKinds) on visible
|
||||||
|
// projects whose created_at is newer than users.inbox_seen_at. See
|
||||||
|
// ApprovalService.UnseenInboxCountForUser for the contract.
|
||||||
|
//
|
||||||
|
// The legacy approval-only count is still reachable via
|
||||||
|
// PendingCountForUser inside the dashboard widget — that path
|
||||||
|
// doesn't go through this endpoint.
|
||||||
func handleInboxCount(w http.ResponseWriter, r *http.Request) {
|
func handleInboxCount(w http.ResponseWriter, r *http.Request) {
|
||||||
if !requireDB(w) {
|
if !requireDB(w) {
|
||||||
return
|
return
|
||||||
@@ -229,7 +240,7 @@ func handleInboxCount(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
n, err := dbSvc.approval.PendingCountForUser(r.Context(), uid)
|
n, err := dbSvc.approval.UnseenInboxCountForUser(r.Context(), uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeServiceError(w, err)
|
writeServiceError(w, err)
|
||||||
return
|
return
|
||||||
@@ -237,6 +248,57 @@ func handleInboxCount(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]int{"count": n})
|
writeJSON(w, http.StatusOK, map[string]int{"count": n})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/inbox/mark-all-seen — advance the caller's inbox read
|
||||||
|
// cursor (paliad.users.inbox_seen_at). Optional body
|
||||||
|
// `{"up_to": "<iso8601>"}` pins the advance to the timestamp of the
|
||||||
|
// newest row the client actually saw — handy when a second tab made
|
||||||
|
// the inbox grow between the read and the click. Missing body =>
|
||||||
|
// advance to now().
|
||||||
|
//
|
||||||
|
// Returns the new cursor as `{"inbox_seen_at": "<iso8601>"}` so the
|
||||||
|
// client can keep its local state in sync.
|
||||||
|
func handleInboxMarkAllSeen(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireDB(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
UpTo string `json:"up_to"`
|
||||||
|
}
|
||||||
|
if r.Body != nil && r.ContentLength > 0 {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var upTo time.Time
|
||||||
|
if body.UpTo != "" {
|
||||||
|
parsed, err := time.Parse(time.RFC3339Nano, body.UpTo)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "up_to must be RFC3339"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
upTo = parsed
|
||||||
|
}
|
||||||
|
if err := dbSvc.approval.MarkInboxSeen(r.Context(), uid, upTo); err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cur, err := dbSvc.approval.InboxSeenAt(r.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]any{}
|
||||||
|
if cur != nil {
|
||||||
|
resp["inbox_seen_at"] = cur.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
// parseInboxFilter pulls common filter knobs off the query string.
|
// parseInboxFilter pulls common filter knobs off the query string.
|
||||||
//
|
//
|
||||||
// Status / EntityType pass through validation: an unrecognised value is
|
// Status / EntityType pass through validation: an unrecognised value is
|
||||||
|
|||||||
@@ -671,6 +671,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
|||||||
protected.HandleFunc("GET /api/inbox/pending-mine", handleListInboxPendingMine)
|
protected.HandleFunc("GET /api/inbox/pending-mine", handleListInboxPendingMine)
|
||||||
protected.HandleFunc("GET /api/inbox/mine", handleListInboxMine)
|
protected.HandleFunc("GET /api/inbox/mine", handleListInboxMine)
|
||||||
protected.HandleFunc("GET /api/inbox/count", handleInboxCount)
|
protected.HandleFunc("GET /api/inbox/count", handleInboxCount)
|
||||||
|
protected.HandleFunc("POST /api/inbox/mark-all-seen", handleInboxMarkAllSeen)
|
||||||
protected.HandleFunc("GET /api/approval-requests/{id}", handleGetApprovalRequest)
|
protected.HandleFunc("GET /api/approval-requests/{id}", handleGetApprovalRequest)
|
||||||
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
|
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
|
||||||
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
|
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
|
||||||
|
|||||||
@@ -1607,6 +1607,95 @@ func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnseenInboxCountForUser returns the unified inbox badge count
|
||||||
|
// (t-paliad-249, Slice A):
|
||||||
|
//
|
||||||
|
// - pending approval_requests where the caller is qualified to
|
||||||
|
// approve (same predicate as PendingCountForUser); these count
|
||||||
|
// regardless of users.inbox_seen_at — pending approvals never
|
||||||
|
// fall behind the cursor.
|
||||||
|
// - project_events with event_type ∈ InboxProjectEventKinds whose
|
||||||
|
// created_at > users.inbox_seen_at (NULL cursor → every row is
|
||||||
|
// unseen) on visible projects, EXCLUDING the caller's own events
|
||||||
|
// (you don't get notified about your own actions) and excluding
|
||||||
|
// the `*_approval_*` audit duplicates of approval_request rows.
|
||||||
|
//
|
||||||
|
// One round-trip; UNION ALL across two SELECTs so the two halves can
|
||||||
|
// use their own indexes.
|
||||||
|
func (s *ApprovalService) UnseenInboxCountForUser(ctx context.Context, callerID uuid.UUID) (int, error) {
|
||||||
|
inboxKinds := InboxProjectEventKinds
|
||||||
|
q := `SELECT COALESCE(SUM(c), 0) FROM (
|
||||||
|
SELECT COUNT(*) AS c
|
||||||
|
FROM paliad.approval_requests ar
|
||||||
|
JOIN paliad.projects p ON p.id = ar.project_id
|
||||||
|
WHERE ar.status = 'pending'
|
||||||
|
AND ar.requested_by <> $1
|
||||||
|
AND ` + approvalEligibilitySQL + `
|
||||||
|
UNION ALL
|
||||||
|
SELECT COUNT(*) AS c
|
||||||
|
FROM paliad.project_events pe
|
||||||
|
JOIN paliad.projects p ON p.id = pe.project_id
|
||||||
|
LEFT JOIN paliad.users u ON u.id = $1
|
||||||
|
WHERE pe.event_type = ANY($2)
|
||||||
|
AND (pe.created_by IS DISTINCT FROM $1)
|
||||||
|
AND (u.inbox_seen_at IS NULL OR pe.created_at > u.inbox_seen_at)
|
||||||
|
AND ` + visibilityPredicatePositional("p", 1) + `
|
||||||
|
) sub`
|
||||||
|
var n int
|
||||||
|
if err := s.db.GetContext(ctx, &n, q, callerID, pq.Array(inboxKinds)); err != nil {
|
||||||
|
return 0, fmt.Errorf("unseen inbox count: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkInboxSeen advances the caller's inbox read cursor.
|
||||||
|
// If `upTo` is zero, advances to now(); otherwise advances to upTo
|
||||||
|
// (used by the client to pin to the newest visible row so a stray
|
||||||
|
// second tab doesn't lose items between the read and the click).
|
||||||
|
//
|
||||||
|
// The cursor only moves forward — calls with upTo < current are
|
||||||
|
// no-ops so a stale tab can't rewind.
|
||||||
|
func (s *ApprovalService) MarkInboxSeen(ctx context.Context, callerID uuid.UUID, upTo time.Time) error {
|
||||||
|
var (
|
||||||
|
q string
|
||||||
|
args []any
|
||||||
|
)
|
||||||
|
if upTo.IsZero() {
|
||||||
|
q = `UPDATE paliad.users
|
||||||
|
SET inbox_seen_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
AND (inbox_seen_at IS NULL OR inbox_seen_at < now())`
|
||||||
|
args = []any{callerID}
|
||||||
|
} else {
|
||||||
|
q = `UPDATE paliad.users
|
||||||
|
SET inbox_seen_at = $2
|
||||||
|
WHERE id = $1
|
||||||
|
AND (inbox_seen_at IS NULL OR inbox_seen_at < $2)`
|
||||||
|
args = []any{callerID, upTo.UTC()}
|
||||||
|
}
|
||||||
|
if _, err := s.db.ExecContext(ctx, q, args...); err != nil {
|
||||||
|
return fmt.Errorf("mark inbox seen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InboxSeenAt returns the caller's current inbox read cursor, or nil
|
||||||
|
// if the user has never marked the inbox as seen. Used by the inbox
|
||||||
|
// run path to overlay the unread_only predicate (t-paliad-249, §3 of
|
||||||
|
// the design doc).
|
||||||
|
func (s *ApprovalService) InboxSeenAt(ctx context.Context, callerID uuid.UUID) (*time.Time, error) {
|
||||||
|
var t sql.NullTime
|
||||||
|
q := `SELECT inbox_seen_at FROM paliad.users WHERE id = $1`
|
||||||
|
if err := s.db.GetContext(ctx, &t, q, callerID); err != nil {
|
||||||
|
return nil, fmt.Errorf("inbox seen lookup: %w", err)
|
||||||
|
}
|
||||||
|
if !t.Valid {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
v := t.Time
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Policy CRUD — paliad.approval_policies (t-paliad-138 + t-paliad-154).
|
// Policy CRUD — paliad.approval_policies (t-paliad-138 + t-paliad-154).
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -44,12 +44,20 @@ var AllSources = []DataSource{
|
|||||||
const SpecVersion = 1
|
const SpecVersion = 1
|
||||||
|
|
||||||
// FilterSpec is the top-level filter description.
|
// FilterSpec is the top-level filter description.
|
||||||
|
//
|
||||||
|
// UnreadOnly (t-paliad-249) is an inbox-specific overlay: when true,
|
||||||
|
// project_event rows older than the caller's inbox_seen_at cursor are
|
||||||
|
// dropped. Pending approval_request rows always survive (the cursor
|
||||||
|
// can't bury an in-flight approval, per the design doc §3 carve-out).
|
||||||
|
// Set by the bar's `unread_only` axis on /inbox; other surfaces leave
|
||||||
|
// it false and the spec is a no-op.
|
||||||
type FilterSpec struct {
|
type FilterSpec struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Sources []DataSource `json:"sources"`
|
Sources []DataSource `json:"sources"`
|
||||||
Scope ScopeSpec `json:"scope"`
|
Scope ScopeSpec `json:"scope"`
|
||||||
Time TimeSpec `json:"time"`
|
Time TimeSpec `json:"time"`
|
||||||
Predicates map[DataSource]Predicates `json:"predicates,omitempty"`
|
Predicates map[DataSource]Predicates `json:"predicates,omitempty"`
|
||||||
|
UnreadOnly bool `json:"unread_only,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScopeSpec narrows which projects contribute rows. Resolved at query
|
// ScopeSpec narrows which projects contribute rows. Resolved at query
|
||||||
@@ -192,11 +200,58 @@ var KnownProjectEventKinds = []string{
|
|||||||
"deadline_created",
|
"deadline_created",
|
||||||
"deadline_completed",
|
"deadline_completed",
|
||||||
"deadline_reopened",
|
"deadline_reopened",
|
||||||
|
"deadline_updated",
|
||||||
|
"deadline_deleted",
|
||||||
|
"deadlines_imported",
|
||||||
"appointment_created",
|
"appointment_created",
|
||||||
"appointment_updated",
|
"appointment_updated",
|
||||||
"appointment_deleted",
|
"appointment_deleted",
|
||||||
"approval_decided",
|
"approval_decided",
|
||||||
"member_role_changed",
|
"member_role_changed",
|
||||||
|
"note_created",
|
||||||
|
"our_side_changed",
|
||||||
|
}
|
||||||
|
|
||||||
|
// InboxProjectEventKinds is the curated sub-list surfaced by default on
|
||||||
|
// /inbox (t-paliad-249, Slice A; head pick Q1=A on 2026-05-25).
|
||||||
|
//
|
||||||
|
// What's in:
|
||||||
|
// - Lifecycle moves the team should notice: project_archived,
|
||||||
|
// project_reparented, project_type_changed.
|
||||||
|
// - Deadline / appointment authoring across the visible scope.
|
||||||
|
// - Notes (`note_created`) and party-side flips
|
||||||
|
// (`our_side_changed`).
|
||||||
|
// - `member_role_changed` — Slice A surfaces it for everyone who can
|
||||||
|
// see the project; Slice B narrows to "role change affects the
|
||||||
|
// viewer or someone above them in the project tree" (head's
|
||||||
|
// refinement #1).
|
||||||
|
//
|
||||||
|
// What's out:
|
||||||
|
// - All `*_approval_*` event_types — duplicates of approval_request
|
||||||
|
// rows. View-service drops them automatically when ApprovalRequest
|
||||||
|
// is also in spec.Sources (see view_service.allowedProjectEventKinds).
|
||||||
|
// - `status_changed`, `project_created` — too granular / authoring
|
||||||
|
// noise.
|
||||||
|
// - `checklist_*` — low signal; surfaces on the project's checklist
|
||||||
|
// tab instead.
|
||||||
|
//
|
||||||
|
// Design ref: docs/design-inbox-overhaul-2026-05-25.md §2 + §12.
|
||||||
|
var InboxProjectEventKinds = []string{
|
||||||
|
"project_archived",
|
||||||
|
"project_reparented",
|
||||||
|
"project_type_changed",
|
||||||
|
"deadline_created",
|
||||||
|
"deadline_completed",
|
||||||
|
"deadline_reopened",
|
||||||
|
"deadline_updated",
|
||||||
|
"deadline_deleted",
|
||||||
|
"deadlines_imported",
|
||||||
|
"appointment_created",
|
||||||
|
"appointment_updated",
|
||||||
|
"appointment_deleted",
|
||||||
|
"note_created",
|
||||||
|
"our_side_changed",
|
||||||
|
"member_role_changed",
|
||||||
}
|
}
|
||||||
|
|
||||||
// validApprovalStatuses are the legal values for entity-side approval_status
|
// validApprovalStatuses are the legal values for entity-side approval_status
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ const (
|
|||||||
RowActionNavigate ListRowAction = "navigate"
|
RowActionNavigate ListRowAction = "navigate"
|
||||||
RowActionCompleteToggle ListRowAction = "complete_toggle"
|
RowActionCompleteToggle ListRowAction = "complete_toggle"
|
||||||
RowActionApprove ListRowAction = "approve"
|
RowActionApprove ListRowAction = "approve"
|
||||||
|
RowActionInbox ListRowAction = "inbox"
|
||||||
RowActionNone ListRowAction = "none"
|
RowActionNone ListRowAction = "none"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,6 +135,7 @@ var KnownRowActions = []ListRowAction{
|
|||||||
RowActionNavigate,
|
RowActionNavigate,
|
||||||
RowActionCompleteToggle,
|
RowActionCompleteToggle,
|
||||||
RowActionApprove,
|
RowActionApprove,
|
||||||
|
RowActionInbox,
|
||||||
RowActionNone,
|
RowActionNone,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,40 +100,48 @@ func EventsSystemView() SystemView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// InboxSystemView returns the SystemView definition for /inbox — the
|
// InboxSystemView returns the SystemView definition for /inbox.
|
||||||
// 4-eye approval surface. The bar's approval_viewer_role chip
|
|
||||||
// cluster narrows to "Zur Genehmigung" / "Eigene Anfragen" /
|
|
||||||
// "Alle sichtbaren". Default is "any_visible" so the page lands on
|
|
||||||
// a populated view for every user (m's 2026-05-08 22:08 dogfood:
|
|
||||||
// "the inbox somehow does not show nothing no more" — the prior
|
|
||||||
// default was approver_eligible, which is empty for users who only
|
|
||||||
// SUBMIT requests and have nothing to approve themselves).
|
|
||||||
//
|
//
|
||||||
// RowAction = RowActionApprove → shape-list.ts renders the approval
|
// t-paliad-249 (Slice A, 2026-05-25) widened the inbox from
|
||||||
// row layout (entity title + diff + approve/reject/revoke buttons)
|
// approval-requests-only to a project-events feed PLUS approval
|
||||||
// and the surface wires action handlers via the rendered data-attrs.
|
// requests. Sources is [ApprovalRequest, ProjectEvent]; the project
|
||||||
|
// rail is narrowed to InboxProjectEventKinds (curated set, head pick
|
||||||
|
// Q1=A). The `*_approval_*` audit events are de-duplicated against
|
||||||
|
// the approval_request rows by view_service.allowedProjectEventKinds.
|
||||||
|
//
|
||||||
|
// Time window defaults to last 30 days; the bar's time-axis chip
|
||||||
|
// can widen or narrow. Sort is newest-first — different from the
|
||||||
|
// pre-249 ascending default; m's inbox metaphor is "what just
|
||||||
|
// happened", not "what's coming up".
|
||||||
|
//
|
||||||
|
// RowAction = RowActionInbox → shape-list.ts dispatches per
|
||||||
|
// row.kind: approval rows get the approve/reject/revoke layout,
|
||||||
|
// project_event rows get a navigate-style stream row.
|
||||||
func InboxSystemView() SystemView {
|
func InboxSystemView() SystemView {
|
||||||
return SystemView{
|
return SystemView{
|
||||||
Slug: "inbox",
|
Slug: "inbox",
|
||||||
Name: "Inbox",
|
Name: "Inbox",
|
||||||
Filter: FilterSpec{
|
Filter: FilterSpec{
|
||||||
Version: SpecVersion,
|
Version: SpecVersion,
|
||||||
Sources: []DataSource{SourceApprovalRequest},
|
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
|
||||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||||
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
|
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldAuto},
|
||||||
Predicates: map[DataSource]Predicates{
|
Predicates: map[DataSource]Predicates{
|
||||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
||||||
ViewerRole: "any_visible",
|
ViewerRole: "any_visible",
|
||||||
Status: []string{"pending"},
|
Status: []string{"pending"},
|
||||||
}},
|
}},
|
||||||
|
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
|
||||||
|
EventTypes: InboxProjectEventKinds,
|
||||||
|
}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Render: RenderSpec{
|
Render: RenderSpec{
|
||||||
Shape: ShapeList,
|
Shape: ShapeList,
|
||||||
List: &ListConfig{
|
List: &ListConfig{
|
||||||
Density: DensityComfortable,
|
Density: DensityComfortable,
|
||||||
Sort: SortDateAsc,
|
Sort: SortDateDesc,
|
||||||
RowAction: RowActionApprove,
|
RowAction: RowActionInbox,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
// Pure-Go tests for the SystemView registry. Each system view's specs
|
// Pure-Go tests for the SystemView registry. Each system view's specs
|
||||||
// must self-validate; the slugs must be reserved.
|
// must self-validate; the slugs must be reserved.
|
||||||
@@ -45,3 +48,63 @@ func TestReservedSlugs_NonReservedAccepted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// InboxSystemView shape — t-paliad-249
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestInboxSystemView_Sources(t *testing.T) {
|
||||||
|
sv := InboxSystemView()
|
||||||
|
if !slices.Contains(sv.Filter.Sources, SourceApprovalRequest) {
|
||||||
|
t.Errorf("InboxSystemView must include SourceApprovalRequest, got %v", sv.Filter.Sources)
|
||||||
|
}
|
||||||
|
if !slices.Contains(sv.Filter.Sources, SourceProjectEvent) {
|
||||||
|
t.Errorf("InboxSystemView must include SourceProjectEvent, got %v", sv.Filter.Sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboxSystemView_DefaultsToPast30d(t *testing.T) {
|
||||||
|
sv := InboxSystemView()
|
||||||
|
if sv.Filter.Time.Horizon != HorizonPast30d {
|
||||||
|
t.Errorf("default horizon must be past_30d, got %q", sv.Filter.Time.Horizon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboxSystemView_RowActionInbox(t *testing.T) {
|
||||||
|
sv := InboxSystemView()
|
||||||
|
if sv.Render.List == nil {
|
||||||
|
t.Fatal("InboxSystemView must define a list config")
|
||||||
|
}
|
||||||
|
if sv.Render.List.RowAction != RowActionInbox {
|
||||||
|
t.Errorf("row_action must be inbox, got %q", sv.Render.List.RowAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboxSystemView_CuratedProjectEventKinds(t *testing.T) {
|
||||||
|
sv := InboxSystemView()
|
||||||
|
preds := sv.Filter.Predicates[SourceProjectEvent]
|
||||||
|
if preds.ProjectEvent == nil {
|
||||||
|
t.Fatal("InboxSystemView must narrow project_event predicates")
|
||||||
|
}
|
||||||
|
got := preds.ProjectEvent.EventTypes
|
||||||
|
if len(got) != len(InboxProjectEventKinds) {
|
||||||
|
t.Errorf("expected %d curated kinds, got %d", len(InboxProjectEventKinds), len(got))
|
||||||
|
}
|
||||||
|
for _, k := range got {
|
||||||
|
if slices.Contains([]string{"status_changed", "project_created"}, k) {
|
||||||
|
t.Errorf("inbox must NOT include noisy kind %q", k)
|
||||||
|
}
|
||||||
|
// No *_approval_* audit duplicates either — view_service dedups
|
||||||
|
// at query time but the curated list shouldn't carry them.
|
||||||
|
if isApprovalAuditKind(k) {
|
||||||
|
t.Errorf("inbox curated list must not include audit-dup %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboxSystemView_NewestFirst(t *testing.T) {
|
||||||
|
sv := InboxSystemView()
|
||||||
|
if sv.Render.List == nil || sv.Render.List.Sort != SortDateDesc {
|
||||||
|
t.Errorf("inbox must sort newest-first by default, got %q", sv.Render.List.Sort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ func (s *EventService) RunSpec(ctx context.Context, userID uuid.UUID, spec Filte
|
|||||||
rows := make([]ViewRow, 0, 256)
|
rows := make([]ViewRow, 0, 256)
|
||||||
bounds := computeViewSpecBounds(time.Now().UTC(), spec.Time)
|
bounds := computeViewSpecBounds(time.Now().UTC(), spec.Time)
|
||||||
|
|
||||||
|
if spec.UnreadOnly && approval != nil {
|
||||||
|
cursor, err := approval.InboxSeenAt(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("inbox cursor lookup: %w", err)
|
||||||
|
}
|
||||||
|
bounds.unreadOnly = true
|
||||||
|
bounds.inboxSeenAt = cursor
|
||||||
|
}
|
||||||
|
|
||||||
for _, src := range spec.Sources {
|
for _, src := range spec.Sources {
|
||||||
switch src {
|
switch src {
|
||||||
case SourceDeadline:
|
case SourceDeadline:
|
||||||
@@ -148,9 +157,16 @@ func (s *EventService) RunSpec(ctx context.Context, userID uuid.UUID, spec Filte
|
|||||||
|
|
||||||
// viewSpecBounds carries the resolved [from, to) window the spec
|
// viewSpecBounds carries the resolved [from, to) window the spec
|
||||||
// translates into. Either bound can be nil (open-ended).
|
// translates into. Either bound can be nil (open-ended).
|
||||||
|
//
|
||||||
|
// inboxSeenAt is set by RunSpec when spec.UnreadOnly=true: the caller's
|
||||||
|
// users.inbox_seen_at cursor pre-resolved once so each source-runner can
|
||||||
|
// overlay it without re-querying the users table. nil means "never
|
||||||
|
// visited" — every row is unread.
|
||||||
type viewSpecBounds struct {
|
type viewSpecBounds struct {
|
||||||
from *time.Time
|
from *time.Time
|
||||||
to *time.Time
|
to *time.Time
|
||||||
|
unreadOnly bool
|
||||||
|
inboxSeenAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
|
func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
|
||||||
@@ -345,6 +361,11 @@ func (s *EventService) runAppointments(ctx context.Context, userID uuid.UUID, sp
|
|||||||
// runProjectEvents queries paliad.project_events with the visibility
|
// runProjectEvents queries paliad.project_events with the visibility
|
||||||
// predicate. The audit table doesn't have a service wrapper today; we
|
// predicate. The audit table doesn't have a service wrapper today; we
|
||||||
// run our own SQL bounded by the spec.
|
// run our own SQL bounded by the spec.
|
||||||
|
//
|
||||||
|
// Inbox semantics (t-paliad-249) kick in when bounds.unreadOnly is set:
|
||||||
|
// the caller's own events are excluded (you don't notify yourself) and
|
||||||
|
// rows older than bounds.inboxSeenAt are dropped. Cursor lookup happens
|
||||||
|
// once in RunSpec — runProjectEvents only consumes the resolved value.
|
||||||
func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
|
func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
|
||||||
conds := []string{visibilityPredicatePositional("p", 1)}
|
conds := []string{visibilityPredicatePositional("p", 1)}
|
||||||
args := []any{userID}
|
args := []any{userID}
|
||||||
@@ -366,6 +387,14 @@ func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, s
|
|||||||
args = append(args, spec.Scope.Projects.IDs)
|
args = append(args, spec.Scope.Projects.IDs)
|
||||||
conds = append(conds, fmt.Sprintf("pe.project_id = ANY($%d)", len(args)))
|
conds = append(conds, fmt.Sprintf("pe.project_id = ANY($%d)", len(args)))
|
||||||
}
|
}
|
||||||
|
if bounds.unreadOnly {
|
||||||
|
// Inbox mode: hide the caller's own actions (no self-notify).
|
||||||
|
conds = append(conds, "pe.created_by IS DISTINCT FROM $1")
|
||||||
|
if bounds.inboxSeenAt != nil {
|
||||||
|
args = append(args, *bounds.inboxSeenAt)
|
||||||
|
conds = append(conds, fmt.Sprintf("pe.created_at > $%d", len(args)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
q := `
|
q := `
|
||||||
SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description,
|
SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description,
|
||||||
@@ -520,6 +549,15 @@ func (s *EventService) runApprovalRequests(ctx context.Context, userID uuid.UUID
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inbox unread-only carve-out (t-paliad-249, design §3):
|
||||||
|
// pending requests always survive; decided rows are subject to
|
||||||
|
// the cursor like any other audit-style item.
|
||||||
|
if bounds.unreadOnly && r.Status != RequestStatusPending {
|
||||||
|
if bounds.inboxSeenAt != nil && !eventDate.After(*bounds.inboxSeenAt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
title := approvalRowTitle(r)
|
title := approvalRowTitle(r)
|
||||||
subtitle := approvalRowSubtitle(r)
|
subtitle := approvalRowSubtitle(r)
|
||||||
detail, _ := json.Marshal(r) // request view already carries everything the UI needs
|
detail, _ := json.Marshal(r) // request view already carries everything the UI needs
|
||||||
@@ -646,15 +684,46 @@ func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
|
|||||||
|
|
||||||
// allowedProjectEventKinds returns the slice of project_event.event_type
|
// allowedProjectEventKinds returns the slice of project_event.event_type
|
||||||
// values the spec narrows to, or nil for "all known kinds".
|
// values the spec narrows to, or nil for "all known kinds".
|
||||||
|
//
|
||||||
|
// Inbox de-dup (t-paliad-249): when the spec also fans out
|
||||||
|
// SourceApprovalRequest, every `*_approval_*` audit event is dropped
|
||||||
|
// — the approval_request row itself is the canonical signal, and we
|
||||||
|
// don't want both rows showing up side-by-side. The drop applies to
|
||||||
|
// both the explicit caller list and the implicit "all kinds" path.
|
||||||
func allowedProjectEventKinds(spec FilterSpec) []string {
|
func allowedProjectEventKinds(spec FilterSpec) []string {
|
||||||
preds, ok := spec.Predicates[SourceProjectEvent]
|
preds, ok := spec.Predicates[SourceProjectEvent]
|
||||||
if !ok || preds.ProjectEvent == nil {
|
dedupApprovals := slices.Contains(spec.Sources, SourceApprovalRequest)
|
||||||
|
|
||||||
|
var requested []string
|
||||||
|
switch {
|
||||||
|
case ok && preds.ProjectEvent != nil && len(preds.ProjectEvent.EventTypes) > 0:
|
||||||
|
requested = preds.ProjectEvent.EventTypes
|
||||||
|
case dedupApprovals:
|
||||||
|
// No explicit narrowing, but ApprovalRequest is in sources —
|
||||||
|
// rebuild the implicit "all" list so we can subtract approvals.
|
||||||
|
requested = KnownProjectEventKinds
|
||||||
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if len(preds.ProjectEvent.EventTypes) == 0 {
|
|
||||||
return nil
|
if !dedupApprovals {
|
||||||
|
return requested
|
||||||
}
|
}
|
||||||
return preds.ProjectEvent.EventTypes
|
filtered := make([]string, 0, len(requested))
|
||||||
|
for _, k := range requested {
|
||||||
|
if isApprovalAuditKind(k) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, k)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// isApprovalAuditKind matches the `*_approval_*` audit event_types that
|
||||||
|
// every approval mutation emits alongside the approval_request row.
|
||||||
|
// Dropped from inbox project_event reads (see allowedProjectEventKinds).
|
||||||
|
func isApprovalAuditKind(kind string) bool {
|
||||||
|
return strings.Contains(kind, "_approval_") || kind == "approval_decided"
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedRequestStatuses returns nil for "no narrowing" (or "single value
|
// allowedRequestStatuses returns nil for "no narrowing" (or "single value
|
||||||
|
|||||||
111
internal/services/view_service_inbox_test.go
Normal file
111
internal/services/view_service_inbox_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// t-paliad-249 — Slice A. Ensures the *_approval_* audit kinds are
|
||||||
|
// dropped from the project_event read path when ApprovalRequest is
|
||||||
|
// also fanning out, so the same fact isn't shown twice in /inbox.
|
||||||
|
|
||||||
|
func TestAllowedProjectEventKinds_DedupsApprovalAudits(t *testing.T) {
|
||||||
|
spec := FilterSpec{
|
||||||
|
Version: SpecVersion,
|
||||||
|
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
|
||||||
|
Predicates: map[DataSource]Predicates{
|
||||||
|
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
|
||||||
|
EventTypes: []string{
|
||||||
|
"deadline_created",
|
||||||
|
"deadline_approval_requested",
|
||||||
|
"appointment_approval_approved",
|
||||||
|
"approval_decided",
|
||||||
|
"note_created",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := allowedProjectEventKinds(spec)
|
||||||
|
if slices.Contains(got, "deadline_approval_requested") {
|
||||||
|
t.Errorf("must drop deadline_approval_requested, got %v", got)
|
||||||
|
}
|
||||||
|
if slices.Contains(got, "appointment_approval_approved") {
|
||||||
|
t.Errorf("must drop appointment_approval_approved, got %v", got)
|
||||||
|
}
|
||||||
|
if slices.Contains(got, "approval_decided") {
|
||||||
|
t.Errorf("must drop approval_decided, got %v", got)
|
||||||
|
}
|
||||||
|
if !slices.Contains(got, "deadline_created") {
|
||||||
|
t.Errorf("must keep deadline_created, got %v", got)
|
||||||
|
}
|
||||||
|
if !slices.Contains(got, "note_created") {
|
||||||
|
t.Errorf("must keep note_created, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowedProjectEventKinds_NoDedupWhenApprovalsAbsent(t *testing.T) {
|
||||||
|
spec := FilterSpec{
|
||||||
|
Version: SpecVersion,
|
||||||
|
Sources: []DataSource{SourceProjectEvent},
|
||||||
|
Predicates: map[DataSource]Predicates{
|
||||||
|
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
|
||||||
|
EventTypes: []string{
|
||||||
|
"deadline_created",
|
||||||
|
"deadline_approval_requested",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := allowedProjectEventKinds(spec)
|
||||||
|
if !slices.Contains(got, "deadline_approval_requested") {
|
||||||
|
t.Errorf("Verlauf-style spec without approvals source should keep audit kinds, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowedProjectEventKinds_NilWhenNoPredicateAndNoDedup(t *testing.T) {
|
||||||
|
spec := FilterSpec{
|
||||||
|
Version: SpecVersion,
|
||||||
|
Sources: []DataSource{SourceProjectEvent},
|
||||||
|
}
|
||||||
|
if got := allowedProjectEventKinds(spec); got != nil {
|
||||||
|
t.Errorf("expected nil (all kinds), got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowedProjectEventKinds_FillsImplicitListWhenDedup(t *testing.T) {
|
||||||
|
// When approvals are in sources but the caller didn't explicitly
|
||||||
|
// narrow EventTypes, the helper must materialise the curated set
|
||||||
|
// minus audit duplicates so the WHERE clause filters them out.
|
||||||
|
spec := FilterSpec{
|
||||||
|
Version: SpecVersion,
|
||||||
|
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
|
||||||
|
}
|
||||||
|
got := allowedProjectEventKinds(spec)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected explicit kind list, got nil")
|
||||||
|
}
|
||||||
|
for _, k := range got {
|
||||||
|
if isApprovalAuditKind(k) {
|
||||||
|
t.Errorf("audit kind %q leaked through dedup", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsApprovalAuditKind(t *testing.T) {
|
||||||
|
cases := map[string]bool{
|
||||||
|
"deadline_approval_requested": true,
|
||||||
|
"appointment_approval_approved": true,
|
||||||
|
"appointment_approval_rejected": true,
|
||||||
|
"deadline_approval_changes_suggested": true,
|
||||||
|
"approval_decided": true,
|
||||||
|
"deadline_created": false,
|
||||||
|
"note_created": false,
|
||||||
|
"our_side_changed": false,
|
||||||
|
"project_archived": false,
|
||||||
|
}
|
||||||
|
for kind, want := range cases {
|
||||||
|
if got := isApprovalAuditKind(kind); got != want {
|
||||||
|
t.Errorf("isApprovalAuditKind(%q) = %v, want %v", kind, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user