Files
pad/internal/store/migrations/155_push_notifications.sql
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

78 lines
4.2 KiB
SQL

-- Migration 155: push notifications (TASK-3937, PLAN-3908 phase 2 unit 1).
--
-- push_devices: one row per signed-in app session that registers a push
-- token (the register endpoint lands with TASK-3938; nothing writes this
-- table yet). Every token is an FCM registration token (iOS routes through FCM),
-- so there is no environment column: FCM picks the APNs gateway itself.
-- platform stays because the FCM payload differs per platform. The row dies
-- with its session or its user.
--
-- user_notifications: the per-event push queue. Nothing writes it yet: the
-- enqueue is TASK-3937 part B, and it is to write a row ONLY for a user who
-- has at least one device, on the transaction of the write that caused it.
-- dedupe_key is what makes that enqueue idempotent per (user, key). item_id
-- and comment_id carry no foreign key: the dispatcher is to skip a row whose
-- subject is gone, and retention to remove the rest. Retention is NOT in this
-- migration: the 7-day sweep lands with the dispatcher (TASK-3939), and until
-- then nothing deletes a queue row. workspace_id cascades, so a purged
-- workspace leaves no queue behind. actor_user_id is indexed because deleting
-- a user runs its ON DELETE SET NULL against this table.
--
-- user_notification_settings: a person's recipient-side filters. No row means
-- the defaults. The dispatcher (TASK-3939) is to apply them; the enqueue
-- (part B) is not to.
--
-- workspace_read_cursors (149) stays separate: it must exist for every user,
-- with or without a device, and this queue is to be deleted after 7 days.
CREATE TABLE IF NOT EXISTS push_devices (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_id TEXT NOT NULL UNIQUE REFERENCES sessions(id) ON DELETE CASCADE,
connection_id TEXT NOT NULL,
platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')),
token TEXT NOT NULL,
app_version TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (user_id, token)
);
CREATE INDEX IF NOT EXISTS idx_push_devices_user ON push_devices(user_id);
CREATE TABLE IF NOT EXISTS user_notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
item_id TEXT NOT NULL,
comment_id TEXT,
kind TEXT NOT NULL CHECK (kind IN ('assigned', 'comment', 'reply', 'reminder', 'mention')),
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
actor_is_agent INTEGER NOT NULL DEFAULT 0 CHECK (actor_is_agent IN (0, 1)),
dedupe_key TEXT NOT NULL,
created_at TEXT NOT NULL,
push_state TEXT NOT NULL DEFAULT 'pending' CHECK (push_state IN ('pending', 'sent', 'skipped', 'expired')),
skip_reason TEXT NOT NULL DEFAULT '',
claim_token TEXT NOT NULL DEFAULT '',
claimed_at TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
sent_at TEXT,
UNIQUE (user_id, dedupe_key)
);
CREATE INDEX IF NOT EXISTS idx_user_notifications_state ON user_notifications(push_state, created_at);
CREATE INDEX IF NOT EXISTS idx_user_notifications_sent ON user_notifications(user_id, sent_at);
CREATE INDEX IF NOT EXISTS idx_user_notifications_workspace ON user_notifications(workspace_id);
CREATE INDEX IF NOT EXISTS idx_user_notifications_actor ON user_notifications(actor_user_id);
CREATE TABLE IF NOT EXISTS user_notification_settings (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
push_enabled INTEGER NOT NULL DEFAULT 1 CHECK (push_enabled IN (0, 1)),
notify_assigned INTEGER NOT NULL DEFAULT 1 CHECK (notify_assigned IN (0, 1)),
notify_comments INTEGER NOT NULL DEFAULT 1 CHECK (notify_comments IN (0, 1)),
notify_replies INTEGER NOT NULL DEFAULT 1 CHECK (notify_replies IN (0, 1)),
notify_reminders INTEGER NOT NULL DEFAULT 1 CHECK (notify_reminders IN (0, 1)),
notify_mentions INTEGER NOT NULL DEFAULT 1 CHECK (notify_mentions IN (0, 1)),
include_agent_activity INTEGER NOT NULL DEFAULT 0 CHECK (include_agent_activity IN (0, 1)),
updated_at TEXT NOT NULL
);