Files
pad/internal/store/notification_settings.go
T
xarmian 9af942a169 feat(store): push notification schema and settings store (TASK-3937 part A) (#2265)
* feat(store): push notification schema and settings store (TASK-3937 part A)

Tables push_devices, user_notifications, user_notification_settings
(sqlite 155 + generated NUL triggers 156, pg 124), the settings model and
store, the migrated.go left-out/refusal entries, and the cascade tests.
The enqueue side is part B.

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

* test(store): ScanNUL cost figure counts the push tables' columns (TASK-3937 part A)

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

* fix(store): index the push queue's actor, say retention is the dispatcher's, cover the queue in the purge test (TASK-3937 part A r1)

- idx_user_notifications_actor on both dialects: deleting a user runs
  actor_user_id's ON DELETE SET NULL against the queue.
- 155's comment no longer claims a 7-day retention that nothing
  implements yet: the sweep lands with the dispatcher (TASK-3939).
- user_notifications joins wsChildTables and is seeded by
  seedFullWorkspace, so the no-orphan and no-over-purge tests cover it.
  Shown able to fail: with workspace_id's cascade removed, the purge
  test reports "table user_notifications left 1 rows".

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

* fix(store): isolate push_devices' user cascade in a test; dispatcher prose says it is TASK-3939's (TASK-3937 part A r3)

- TestPushDevices_UserCascade binds a device to another user's session
  and deletes the device's user, so only push_devices.user_id's cascade
  can clear the row. TestPushTables_Cascades could not: account deletion
  removes the user's sessions first. Shown able to fail: with the cascade
  removed from 155, the delete is refused (FOREIGN KEY constraint failed).
- TestPushTables_Cascades' header lists exactly what it kills.
- migrated.go, 155, models and nulcolumns no longer describe a
  dispatcher or 7-day sweep as if they ran today: both land with
  TASK-3939, and until then nothing reads or deletes the queue.

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

* docs(store): the push-delivery note names the census test; five groups are left out (TASK-3937 part A)

Rebased onto 393d986ea, which carries BUG-3958's
TestMigratedEveryLiveTableIsClassified. Part A's first commit now
classifies its tables there: user_notification_settings refused,
push_devices and user_notifications excluded as "push-delivery". Shown
able to fail: without the push_devices entry, the census test reports
"[push_devices]". The header's group count goes from four to five
with the PUSH DELIVERY STATE group.

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

* docs(store): push prose describes the enqueue, registration and dispatcher as owed, not built (TASK-3937 part A r4 L1)

Ruled a fix once part B could not merge in the same window as A
(r4 hunter L1). 155's header, migrated.go's push-delivery notes and
the settings model now say the enqueue is part B's, registration
TASK-3938's and the dispatcher TASK-3939's. nulExcluded's push reasons
gain a header saying they are the writers' contract, and each names the
unit that owes it: device columns TASK-3938, queue columns part B, the
claim and send columns TASK-3939. user_notification_settings keeps
TASK-3937; PutNotificationSettings writes it today.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-20 19:06:53 -04:00

56 lines
2.2 KiB
Go

package store
import (
"database/sql"
"errors"
"fmt"
"github.com/PerpetualSoftware/pad/internal/models"
)
// GetNotificationSettings returns userID's push settings, or the defaults when
// they have never saved any (TASK-3937). A missing row is not an error.
func (s *Store) GetNotificationSettings(userID string) (models.NotificationSettings, error) {
var out models.NotificationSettings
err := s.db.QueryRow(s.q(`
SELECT push_enabled, notify_assigned, notify_comments, notify_replies,
notify_reminders, notify_mentions, include_agent_activity
FROM user_notification_settings WHERE user_id = ?`), userID).Scan(
&out.PushEnabled, &out.NotifyAssigned, &out.NotifyComments, &out.NotifyReplies,
&out.NotifyReminders, &out.NotifyMentions, &out.IncludeAgentActivity)
if errors.Is(err, sql.ErrNoRows) {
return models.DefaultNotificationSettings(), nil
}
if err != nil {
return models.NotificationSettings{}, fmt.Errorf("get notification settings: %w", err)
}
return out, nil
}
// PutNotificationSettings replaces userID's settings wholesale: a field left
// false is stored false, so a client that turns one off and sends the rest
// unchanged cannot lose the choice (TASK-3937).
func (s *Store) PutNotificationSettings(userID string, in models.NotificationSettings) error {
b := s.dialect.BoolToInt
_, err := s.db.Exec(s.q(`
INSERT INTO user_notification_settings
(user_id, push_enabled, notify_assigned, notify_comments, notify_replies,
notify_reminders, notify_mentions, include_agent_activity, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id) DO UPDATE SET
push_enabled = excluded.push_enabled,
notify_assigned = excluded.notify_assigned,
notify_comments = excluded.notify_comments,
notify_replies = excluded.notify_replies,
notify_reminders = excluded.notify_reminders,
notify_mentions = excluded.notify_mentions,
include_agent_activity = excluded.include_agent_activity,
updated_at = excluded.updated_at`),
userID, b(in.PushEnabled), b(in.NotifyAssigned), b(in.NotifyComments), b(in.NotifyReplies),
b(in.NotifyReminders), b(in.NotifyMentions), b(in.IncludeAgentActivity), now())
if err != nil {
return fmt.Errorf("put notification settings: %w", err)
}
return nil
}