mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
dbbbefda75
* feat(store): record a delivery by id, unclaim without an attempt, re-check holds (TASK-3939 B1) RecordNotificationsSent is token-free for pending|expired rows (r4-L4), UnclaimNotifications hands never-tried rows back without an attempt, HeldNotifications is the pre-send re-check. The claim error drops the row id (Q8), notify.SendTimeout is exported (Q4), and the store doc says SENT is the one deliberate exception to settle-under-the-claim (Q5). Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * feat(server): the push dispatcher core and its privacy gates (TASK-3939 B1) Claims settled rows, runs membership, liveness, visibility, assignment, comment, settings, agent-activity and device gates on each (a skip carries a recorded reason and never reaches the gateway), groups survivors per (user, item), sends one content-free push per live device and settles per chunk. Lease >= sendTimeout + margin is enforced by the setter, the send deadline and a per-chunk time check. Not wired into cmd/pad. Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * test(server): dispatcher gates, lease invariant and late-send recording (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * test(server): an unknown notification kind is skipped (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * fix(server): the dispatcher logs no ids, settles zero-message units, checks the comment's item and has a clock seam (TASK-3939 B1) C1 reword the store header (the dispatcher is wired in part B2). H1 split computeWatchAccessVisibility into a log-free core the dispatcher calls; the handler's wrapper logs exactly as before. H2 settle a unit with no message as a no_device skip. H4 a mention/comment/reply row's comment must be on the row's item and workspace. Add the unexported now seam. T3 comment on the nil user branch. Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU * test(store): settle primitives assert attempts, sent_at and span more than one id chunk (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU * test(server): dispatcher fix round, clock-seam lease tests, every gate error, gate order, log leak, zero-message unit (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU
301 lines
12 KiB
Go
301 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// The push queue's enqueue side (TASK-3937). Every function here runs on the
|
|
// CALLER's transaction, so a notification commits with the write that caused
|
|
// it or not at all. The settings a recipient has chosen are the dispatcher's
|
|
// to apply, not the enqueue's.
|
|
//
|
|
// A row is written ONLY for a user with at least one push_devices row (the
|
|
// EXISTS in enqueueNotificationsTx), so until device registration lands
|
|
// (TASK-3938) the enqueue writes nothing. dedupe_key's ON CONFLICT makes it
|
|
// idempotent per (user, key). Migration 155's header predates this file and
|
|
// calls the enqueue owed: merged migrations are immutable, so this comment,
|
|
// not that one, is the current description.
|
|
//
|
|
// Recipients are NOT access-checked here. Removing a member leaves them an
|
|
// item's created_by and possibly its assignee, so a row can be written for
|
|
// someone who can no longer see the item. Once the dispatcher is wired
|
|
// into the server (TASK-3939 part B2; part B1 only builds it), it re-checks
|
|
// current membership and item visibility at send and skips the row: that is
|
|
// the privacy gate.
|
|
//
|
|
// @mention notifications (TASK-3946) are built from the users
|
|
// reindexCommentMentionsTx newly indexed, at the comment create and edit
|
|
// sites only: never on a tombstone, an import, a cross-workspace copy (which
|
|
// copies no comments) or the backfill.
|
|
|
|
const (
|
|
notifyAssigned = "assigned"
|
|
notifyComment = "comment"
|
|
notifyReply = "reply"
|
|
notifyReminder = "reminder"
|
|
notifyMention = "mention"
|
|
)
|
|
|
|
// notifyFact is one notification owed to one user.
|
|
type notifyFact struct {
|
|
UserID string
|
|
WorkspaceID string
|
|
ItemID string
|
|
CommentID string
|
|
Kind string
|
|
ActorUserID string
|
|
ActorIsAgent bool
|
|
// DedupeKey is unique per user: enqueuing the same fact twice writes one
|
|
// row. Built from a kind and server-minted ids, never from request text.
|
|
DedupeKey string
|
|
}
|
|
|
|
// enqueueNotificationsTx writes each fact for its user, and only for a user
|
|
// who has at least one registered device: a person who never installed the
|
|
// app accumulates nothing. The EXISTS gate is in the statement, not a prior
|
|
// read, so a device registered in the same instant cannot be missed or
|
|
// double-counted. The SELECT form carries a WHERE because SQLite misparses
|
|
// INSERT ... SELECT ... ON CONFLICT without one.
|
|
func (s *Store) enqueueNotificationsTx(tx *sql.Tx, facts []notifyFact) error {
|
|
_, err := s.enqueueNotificationsCountTx(tx, facts)
|
|
return err
|
|
}
|
|
|
|
// enqueueNotificationsCountTx is enqueueNotificationsTx reporting which facts
|
|
// wrote a row: a fact gated out by the device EXISTS, or already present
|
|
// under its dedupe key, reports false.
|
|
func (s *Store) enqueueNotificationsCountTx(tx *sql.Tx, facts []notifyFact) ([]bool, error) {
|
|
agentParam := "CAST(? AS INTEGER)"
|
|
if s.dialect.Driver() == DriverPostgres {
|
|
agentParam = "CAST(? AS BOOLEAN)"
|
|
}
|
|
q := s.q(`
|
|
INSERT INTO user_notifications
|
|
(id, user_id, workspace_id, item_id, comment_id, kind, actor_user_id, actor_is_agent, dedupe_key, created_at)
|
|
SELECT ?, ?, ?, ?, NULLIF(?, ''), ?, NULLIF(?, ''), ` + agentParam + `, ?, ?
|
|
WHERE EXISTS (SELECT 1 FROM push_devices WHERE user_id = ?)
|
|
ON CONFLICT (user_id, dedupe_key) DO NOTHING`)
|
|
ts := now()
|
|
wrote := make([]bool, len(facts))
|
|
for i, f := range facts {
|
|
if f.UserID == "" {
|
|
continue
|
|
}
|
|
res, err := tx.Exec(q,
|
|
newID(), f.UserID, f.WorkspaceID, f.ItemID, f.CommentID, f.Kind, f.ActorUserID,
|
|
s.dialect.BoolToInt(f.ActorIsAgent), f.DedupeKey, ts, f.UserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("enqueue %s notification: %w", f.Kind, err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("enqueue %s notification: %w", f.Kind, err)
|
|
}
|
|
wrote[i] = n == 1
|
|
}
|
|
return wrote, nil
|
|
}
|
|
|
|
// enqueueAssignedNotificationTx tells toUser they were given item. Assigning
|
|
// yourself notifies nobody; an empty actor (a system write) is not "you".
|
|
func (s *Store) enqueueAssignedNotificationTx(tx *sql.Tx, item *models.Item, toUser, actorUserID string, actorIsAgent bool) error {
|
|
if toUser == "" || toUser == actorUserID {
|
|
return nil
|
|
}
|
|
return s.enqueueNotificationsTx(tx, []notifyFact{{
|
|
UserID: toUser, WorkspaceID: item.WorkspaceID, ItemID: item.ID, Kind: notifyAssigned,
|
|
ActorUserID: actorUserID, ActorIsAgent: actorIsAgent,
|
|
DedupeKey: fmt.Sprintf("assigned:%s:%d", item.ID, item.Seq),
|
|
}})
|
|
}
|
|
|
|
// commentRecipients collects who a comment concerns, first kind wins. It is a
|
|
// type rather than a closure so the appstore boundary walk can resolve it.
|
|
type commentRecipients struct {
|
|
order []string
|
|
kinds map[string]string
|
|
}
|
|
|
|
// add records user under kind unless they are the actor, empty, or already in.
|
|
func (r *commentRecipients) add(user, kind, actorUserID string) {
|
|
if user == "" || user == actorUserID {
|
|
return
|
|
}
|
|
if r.kinds == nil {
|
|
r.kinds = map[string]string{}
|
|
}
|
|
if _, seen := r.kinds[user]; !seen {
|
|
r.order = append(r.order, user)
|
|
r.kinds[user] = kind
|
|
}
|
|
}
|
|
|
|
// enqueueCommentNotificationsTx tells the people a new comment concerns: the
|
|
// item's assignee and creator ("comment"), and the author of the comment it
|
|
// replies to ("reply"). The author is never told about their own comment. A
|
|
// person who is both a reply target and an assignee gets the one "reply",
|
|
// unless they are also mentioned (below).
|
|
// A tombstoned parent still names its author; the dispatcher filters later.
|
|
//
|
|
// mentioned is the users the comment's body newly indexed (TASK-3946). Those
|
|
// who can see the item are added first, so a mentioned person who is also a
|
|
// reply target, assignee or creator gets the one "mention" and no second row.
|
|
// A mention of someone who cannot see the item writes nothing, and they keep
|
|
// whatever other role they have.
|
|
func (s *Store) enqueueCommentNotificationsTx(tx *sql.Tx, c *models.Comment, mentioned []string, actorUserID string, actorIsAgent bool) error {
|
|
var assigned, creator sql.NullString
|
|
if err := tx.QueryRow(s.q(`SELECT assigned_user_id, created_by_user_id FROM items WHERE id = ?`), c.ItemID).
|
|
Scan(&assigned, &creator); err != nil {
|
|
return fmt.Errorf("read comment notification recipients: %w", err)
|
|
}
|
|
visible, err := s.mentionRecipientsTx(tx, c, mentioned)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var to commentRecipients
|
|
for _, u := range visible {
|
|
to.add(u, notifyMention, actorUserID)
|
|
}
|
|
// The parent's author is added before the item's people, so a person who
|
|
// is also the assignee keeps the "reply" and gets no second row.
|
|
if c.ParentID != "" {
|
|
var parentAuthor sql.NullString
|
|
err := tx.QueryRow(s.q(`SELECT user_id FROM comments WHERE id = ?`), c.ParentID).Scan(&parentAuthor)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return fmt.Errorf("read parent comment author: %w", err)
|
|
}
|
|
to.add(parentAuthor.String, notifyReply, actorUserID)
|
|
}
|
|
to.add(assigned.String, notifyComment, actorUserID)
|
|
to.add(creator.String, notifyComment, actorUserID)
|
|
return s.enqueueNotificationsTx(tx, to.facts(c, actorUserID, actorIsAgent))
|
|
}
|
|
|
|
// facts turns the collected recipients into one fact each for comment c.
|
|
func (r *commentRecipients) facts(c *models.Comment, actorUserID string, actorIsAgent bool) []notifyFact {
|
|
out := make([]notifyFact, 0, len(r.order))
|
|
for _, u := range r.order {
|
|
kind := r.kinds[u]
|
|
key := "comment:" + c.ID
|
|
if kind == notifyMention {
|
|
key = mentionDedupeKey(c.ID, u)
|
|
}
|
|
out = append(out, notifyFact{
|
|
UserID: u, WorkspaceID: c.WorkspaceID, ItemID: c.ItemID, CommentID: c.ID, Kind: kind,
|
|
ActorUserID: actorUserID, ActorIsAgent: actorIsAgent,
|
|
DedupeKey: key,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mentionDedupeKey(commentID, userID string) string {
|
|
return "mention:" + commentID + ":" + userID
|
|
}
|
|
|
|
// enqueueMentionNotificationsTx tells the users an EDIT newly mentioned. The
|
|
// actor is the EDITOR, who wrote the mention: an admin adding @bob to Alice's
|
|
// comment is the admin mentioning bob, and an admin adding @alice notifies
|
|
// Alice (TASK-3946 ruling e6a86278). The editor is never told about
|
|
// mentioning themselves.
|
|
//
|
|
// Mention wins (TASK-3946 lead ruling f0fc5933): when the mention row is
|
|
// actually written, the same user's still-pending comment/reply row for this
|
|
// comment is deleted, by a conditional DELETE and never a read first, so a
|
|
// dispatcher claim that lands in between leaves both rows. A row already
|
|
// sent, skipped or expired stays, and the mention is written anyway. A
|
|
// mention that writes nothing (no device, an existing mention row under the
|
|
// dedupe key, or a user who cannot see the item) deletes nothing.
|
|
func (s *Store) enqueueMentionNotificationsTx(tx *sql.Tx, c *models.Comment, added []string, actorUserID string, actorIsAgent bool) error {
|
|
visible, err := s.mentionRecipientsTx(tx, c, added)
|
|
if err != nil || len(visible) == 0 {
|
|
return err
|
|
}
|
|
var to commentRecipients
|
|
for _, u := range visible {
|
|
to.add(u, notifyMention, actorUserID)
|
|
}
|
|
facts := to.facts(c, actorUserID, actorIsAgent)
|
|
wrote, err := s.enqueueNotificationsCountTx(tx, facts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, f := range facts {
|
|
if !wrote[i] {
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(s.q(`
|
|
DELETE FROM user_notifications
|
|
WHERE comment_id = ? AND user_id = ? AND kind IN ('comment', 'reply') AND push_state = 'pending'`),
|
|
c.ID, f.UserID); err != nil {
|
|
return fmt.Errorf("supersede comment notification: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mentionRecipientsTx keeps the mentioned users who can see c's item
|
|
// (TASK-3946, binding rule 645fab20): comment_mentions indexes any resolved
|
|
// human, so a non-member, a no-grant guest, or a restricted member outside
|
|
// the item's collection must not be told. Visibility is resolved as for a
|
|
// bearer caller, so a platform admin is judged by membership and grants,
|
|
// never by the admin role.
|
|
func (s *Store) mentionRecipientsTx(tx *sql.Tx, c *models.Comment, users []string) ([]string, error) {
|
|
if len(users) == 0 {
|
|
return nil, nil
|
|
}
|
|
var collectionID string
|
|
if err := tx.QueryRow(s.q(`SELECT collection_id FROM items WHERE id = ?`), c.ItemID).Scan(&collectionID); err != nil {
|
|
return nil, fmt.Errorf("read mention item collection: %w", err)
|
|
}
|
|
var out []string
|
|
for _, u := range users {
|
|
// nil/nil below means unrestricted, but the helper also returns it
|
|
// for a user that no longer exists, so existence is checked first.
|
|
if user, err := s.GetUserQ(tx, u); err != nil {
|
|
return nil, fmt.Errorf("read mentioned user: %w", err)
|
|
} else if user == nil {
|
|
continue
|
|
}
|
|
full, granted, err := s.ResolveBacklinksVisibilityQ(tx, u, c.WorkspaceID, false, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve mention visibility: %w", err)
|
|
}
|
|
if (full == nil && granted == nil) || slices.Contains(full, collectionID) || slices.Contains(granted, c.ItemID) {
|
|
out = append(out, u)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// enqueueReminderNotificationTx tells the item's assignee, or its creator when
|
|
// nobody holds it, that a reminder fired. A reminder has no owner column, and
|
|
// the key carries fired_at so a re-armed reminder that fires again is a new
|
|
// notification. There is no actor: the clock fired it.
|
|
func (s *Store) enqueueReminderNotificationTx(tx *sql.Tx, r *models.Reminder, item *models.Item) error {
|
|
to := ""
|
|
if item.AssignedUserID != nil {
|
|
to = *item.AssignedUserID
|
|
}
|
|
if to == "" {
|
|
var creator sql.NullString
|
|
if err := tx.QueryRow(s.q(`SELECT created_by_user_id FROM items WHERE id = ?`), item.ID).Scan(&creator); err != nil {
|
|
return fmt.Errorf("read reminder notification recipient: %w", err)
|
|
}
|
|
to = creator.String
|
|
}
|
|
fired := ""
|
|
if r.FiredAt != nil {
|
|
fired = *r.FiredAt
|
|
}
|
|
return s.enqueueNotificationsTx(tx, []notifyFact{{
|
|
UserID: to, WorkspaceID: r.WorkspaceID, ItemID: r.ItemID, Kind: notifyReminder,
|
|
DedupeKey: "reminder:" + r.ID + ":" + fired,
|
|
}})
|
|
}
|