internal/store/inbox.go
160 lines · 4947 bytes
1package store
2
3import (
4 "strconv"
5 "strings"
6)
7
8// Notice is one inbox row: what happened, where, and whether it has been
9// read. Path is the web path without a leading slash, so a client turns it
10// into a link by prefixing the site URL.
11type Notice struct {
12 ID int64
13 RepoPath string
14 Kind string
15 Actor string
16 Summary string
17 Path string
18 CreatedAt string
19 ReadAt string
20}
21
22// inboxSelect resolves the repository path the same way the dashboard
23// queries do, since repos.owner_id is polymorphic over users and orgs.
24const inboxSelect = `
25 SELECT n.id, COALESCE(u.username, o.name) || '/' || r.name,
26 n.kind, n.actor, n.summary, n.path, n.created_at, COALESCE(n.read_at, '')
27 FROM inbox n
28 JOIN repos r ON r.id = n.repo_id
29 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
30 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
31 WHERE n.user_id = ?1`
32
33// AddNotice files one inbox row. Best-effort like the mail it accompanies:
34// the action it reports has already succeeded.
35func (s *Store) AddNotice(userID, repoID int64, kind, actor, summary, path string) error {
36 _, err := s.DB.Exec(`
37 INSERT INTO inbox (user_id, repo_id, kind, actor, summary, path)
38 VALUES (?, ?, ?, ?, ?, ?)`,
39 userID, repoID, kind, actor, summary, path)
40 return err
41}
42
43// Inbox returns a user's notices, newest first. unreadOnly drops what has
44// been read; afterID pages backwards from a previous page's last row.
45func (s *Store) Inbox(userID int64, unreadOnly bool, limit int, afterID int64) ([]Notice, error) {
46 q, args := inboxSelect, []any{userID}
47 if unreadOnly {
48 q += " AND n.read_at IS NULL"
49 }
50 if afterID > 0 {
51 q += " AND n.id < ?2"
52 args = append(args, afterID)
53 }
54 q += " ORDER BY n.id DESC"
55 if limit > 0 {
56 q += " LIMIT ?" + strconv.Itoa(len(args)+1)
57 args = append(args, limit)
58 }
59 rows, err := s.DB.Query(q, args...)
60 if err != nil {
61 return nil, err
62 }
63 defer rows.Close()
64 var out []Notice
65 for rows.Next() {
66 var n Notice
67 if err := rows.Scan(&n.ID, &n.RepoPath, &n.Kind, &n.Actor, &n.Summary,
68 &n.Path, &n.CreatedAt, &n.ReadAt); err != nil {
69 return nil, err
70 }
71 out = append(out, n)
72 }
73 return out, rows.Err()
74}
75
76// UnreadNotices counts what the badge shows.
77func (s *Store) UnreadNotices(userID int64) int {
78 var n int
79 s.DB.QueryRow("SELECT COUNT(*) FROM inbox WHERE user_id = ? AND read_at IS NULL",
80 userID).Scan(&n)
81 return n
82}
83
84// MarkNoticesRead marks the given ids read, or every unread notice when
85// ids is empty. It returns how many rows changed. Ids belonging to another
86// user match nothing, so one user cannot touch another's inbox.
87func (s *Store) MarkNoticesRead(userID int64, ids []int64) (int64, error) {
88 const set = "UPDATE inbox SET read_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE user_id = ? AND read_at IS NULL"
89 args := []any{userID}
90 q := set
91 if len(ids) > 0 {
92 q += " AND id IN (" + strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") + ")"
93 for _, id := range ids {
94 args = append(args, id)
95 }
96 }
97 res, err := s.DB.Exec(q, args...)
98 if err != nil {
99 return 0, err
100 }
101 return res.RowsAffected()
102}
103
104// SetRepoWatch records an explicit watch or mute. Re-running it with the
105// other state replaces the row.
106func (s *Store) SetRepoWatch(repoID, userID int64, state string) error {
107 _, err := s.DB.Exec(`
108 INSERT INTO repo_watchers (repo_id, user_id, state) VALUES (?, ?, ?)
109 ON CONFLICT (repo_id, user_id) DO UPDATE SET state = excluded.state`,
110 repoID, userID, state)
111 return err
112}
113
114// RepoWatchState returns "watching", "muted", or "" for the default.
115func (s *Store) RepoWatchState(repoID, userID int64) string {
116 var state string
117 s.DB.QueryRow("SELECT state FROM repo_watchers WHERE repo_id = ? AND user_id = ?",
118 repoID, userID).Scan(&state)
119 return state
120}
121
122// NotifyRecipients is who actually hears about something on a repository:
123// the callers targets — owners, or a thread's participants — widened by
124// the repository's watchers, minus the actor and minus anyone who muted
125// it. Muting wins over every other reason to be told, including owning
126// the repository or having written the thread.
127func (s *Store) NotifyRecipients(repoID, actorID int64, targets []int64) ([]int64, error) {
128 rows, err := s.DB.Query("SELECT user_id, state FROM repo_watchers WHERE repo_id = ?", repoID)
129 if err != nil {
130 return nil, err
131 }
132 defer rows.Close()
133 skip := map[int64]bool{actorID: true}
134 var watching []int64
135 for rows.Next() {
136 var id int64
137 var state string
138 if err := rows.Scan(&id, &state); err != nil {
139 return nil, err
140 }
141 if state == "muted" {
142 skip[id] = true
143 } else {
144 watching = append(watching, id)
145 }
146 }
147 if err := rows.Err(); err != nil {
148 return nil, err
149 }
150 var out []int64
151 seen := map[int64]bool{}
152 for _, id := range append(append([]int64{}, targets...), watching...) {
153 if skip[id] || seen[id] {
154 continue
155 }
156 seen[id] = true
157 out = append(out, id)
158 }
159 return out, nil
160}