Commit 2979e93075

2979e930750dfc473e2e3f5e061de0d1f2abee8f

parent: 94878ec5b8

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 09:15 UTC

store: push device registrations

Migration 0059 adds push_devices, push_queue and users.notify_push. A
re-registered token changes hands rather than erroring, since Apple
reuses tokens across reinstalls.

Ref #89
internal/store/migrations/0059_push.down.sql added +3
@@ -0,0 +1,3 @@
1DROP TABLE push_queue;
2DROP TABLE push_devices;
3ALTER TABLE users DROP COLUMN notify_push;
internal/store/migrations/0059_push.up.sql added +33
@@ -0,0 +1,33 @@
1-- Apple devices an account has registered, and the queue of pushes bound
2-- for them. The mail queue's table is named `notifications`, so this one
3-- cannot be; the columns mirror it so the drainer is the mailer's loop.
4CREATE TABLE push_devices (
5 id INTEGER PRIMARY KEY,
6 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
7 token TEXT NOT NULL UNIQUE,
8 label TEXT NOT NULL DEFAULT '',
9 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
10 last_seen_at TEXT
11);
12CREATE INDEX push_devices_user ON push_devices(user_id);
13
14CREATE TABLE push_queue (
15 id INTEGER PRIMARY KEY,
16 device_id INTEGER NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE,
17 title TEXT NOT NULL,
18 body TEXT NOT NULL,
19 path TEXT NOT NULL,
20 attempts INTEGER NOT NULL DEFAULT 0,
21 next_attempt_at TEXT,
22 sent_at TEXT,
23 failed_at TEXT,
24 last_error TEXT,
25 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
26);
27CREATE INDEX push_queue_due ON push_queue(next_attempt_at)
28 WHERE sent_at IS NULL AND failed_at IS NULL;
29
30-- Whether activity reaches the account's registered devices. Defaults on
31-- and costs nothing for an account with no devices; it exists so a user
32-- with a phone and an iPad silences both without deregistering each.
33ALTER TABLE users ADD COLUMN notify_push INTEGER NOT NULL DEFAULT 1;
internal/store/push.go added +88
@@ -0,0 +1,88 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6)
7
8// PushDevice is one Apple device an account has registered. Token is the
9// APNs device token: an address, not a credential, but device-identifying
10// and never logged or echoed in full.
11type PushDevice struct {
12 ID int64
13 UserID int64
14 Token string
15 Label string
16 CreatedAt string
17 LastSeenAt string
18}
19
20// AddPushDevice registers a token to an account. A token already present
21// changes hands rather than erroring: Apple reuses tokens, and a reinstall
22// hands the same one to whichever account signs in next.
23func (s *Store) AddPushDevice(userID int64, token, label string) (int64, error) {
24 res, err := s.DB.Exec(`
25 INSERT INTO push_devices (user_id, token, label) VALUES (?, ?, ?)
26 ON CONFLICT(token) DO UPDATE SET user_id = excluded.user_id, label = excluded.label`,
27 userID, token, label)
28 if err != nil {
29 return 0, err
30 }
31 return res.LastInsertId()
32}
33
34func (s *Store) PushDevices(userID int64) ([]PushDevice, error) {
35 rows, err := s.DB.Query(`
36 SELECT id, user_id, token, label, created_at, COALESCE(last_seen_at, '')
37 FROM push_devices WHERE user_id = ? ORDER BY id`, userID)
38 if err != nil {
39 return nil, err
40 }
41 defer rows.Close()
42 var out []PushDevice
43 for rows.Next() {
44 var d PushDevice
45 if err := rows.Scan(&d.ID, &d.UserID, &d.Token, &d.Label, &d.CreatedAt, &d.LastSeenAt); err != nil {
46 return nil, err
47 }
48 out = append(out, d)
49 }
50 return out, rows.Err()
51}
52
53// RemovePushDevice deletes one of the account's own devices. Scoping the
54// delete by user_id rather than checking ownership first means another
55// account's id is ErrNotFound, which is the same answer as an id that
56// never existed — a caller learns nothing about other accounts' devices.
57func (s *Store) RemovePushDevice(userID, id int64) error {
58 res, err := s.DB.Exec("DELETE FROM push_devices WHERE id = ? AND user_id = ?", id, userID)
59 if err != nil {
60 return err
61 }
62 n, err := res.RowsAffected()
63 if err != nil {
64 return err
65 }
66 if n == 0 {
67 return ErrNotFound
68 }
69 return nil
70}
71
72func (s *Store) PushEnabled(userID int64) (bool, error) {
73 var on int
74 err := s.DB.QueryRow("SELECT notify_push FROM users WHERE id = ?", userID).Scan(&on)
75 if errors.Is(err, sql.ErrNoRows) {
76 return false, ErrNotFound
77 }
78 return on != 0, err
79}
80
81func (s *Store) SetPushEnabled(userID int64, on bool) error {
82 v := 0
83 if on {
84 v = 1
85 }
86 _, err := s.DB.Exec("UPDATE users SET notify_push = ? WHERE id = ?", v, userID)
87 return err
88}
internal/store/push_test.go added +80
@@ -0,0 +1,80 @@
1package store
2
3import "testing"
4
5func pushFixture(t *testing.T) *Store {
6 t.Helper()
7 s := open(t)
8 if err := s.MigrateUp(); err != nil {
9 t.Fatal(err)
10 }
11 return s
12}
13
14func TestPushDevices(t *testing.T) {
15 s := pushFixture(t)
16 uid, err := s.CreateUser("alice", false)
17 if err != nil {
18 t.Fatal(err)
19 }
20
21 if _, err := s.AddPushDevice(uid, "tok-a", "iphone"); err != nil {
22 t.Fatalf("AddPushDevice: %v", err)
23 }
24 devices, err := s.PushDevices(uid)
25 if err != nil {
26 t.Fatalf("PushDevices: %v", err)
27 }
28 if len(devices) != 1 || devices[0].Token != "tok-a" || devices[0].Label != "iphone" {
29 t.Fatalf("got %+v", devices)
30 }
31
32 // Apple reuses tokens: re-registering updates the label and the owner
33 // rather than erroring, so a reinstall under another account works.
34 bob, err := s.CreateUser("bob", false)
35 if err != nil {
36 t.Fatal(err)
37 }
38 if _, err := s.AddPushDevice(bob, "tok-a", "ipad"); err != nil {
39 t.Fatalf("re-register: %v", err)
40 }
41 if d, _ := s.PushDevices(uid); len(d) != 0 {
42 t.Fatalf("token still owned by alice: %+v", d)
43 }
44 d, _ := s.PushDevices(bob)
45 if len(d) != 1 || d[0].Label != "ipad" {
46 t.Fatalf("got %+v", d)
47 }
48
49 // Removal is scoped to the owner: alice cannot remove bob's device.
50 if err := s.RemovePushDevice(uid, d[0].ID); err != ErrNotFound {
51 t.Fatalf("cross-account remove: got %v, want ErrNotFound", err)
52 }
53 if err := s.RemovePushDevice(bob, d[0].ID); err != nil {
54 t.Fatalf("RemovePushDevice: %v", err)
55 }
56 if d, _ := s.PushDevices(bob); len(d) != 0 {
57 t.Fatalf("device survived removal: %+v", d)
58 }
59}
60
61func TestPushEnabledDefaultsOn(t *testing.T) {
62 s := pushFixture(t)
63 uid, err := s.CreateUser("alice", false)
64 if err != nil {
65 t.Fatal(err)
66 }
67 on, err := s.PushEnabled(uid)
68 if err != nil {
69 t.Fatalf("PushEnabled: %v", err)
70 }
71 if !on {
72 t.Fatal("notify_push should default on")
73 }
74 if err := s.SetPushEnabled(uid, false); err != nil {
75 t.Fatalf("SetPushEnabled: %v", err)
76 }
77 if on, _ := s.PushEnabled(uid); on {
78 t.Fatal("SetPushEnabled(false) did not stick")
79 }
80}