internal/store/inbox.go
212 lines · 6423 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// ClearRepoWatch returns the user to the default: notified about work
115// they are part of, nothing more.
116func (s *Store) ClearRepoWatch(repoID, userID int64) error {
117 _, err := s.DB.Exec("DELETE FROM repo_watchers WHERE repo_id = ? AND user_id = ?", repoID, userID)
118 return err
119}
120
121// RepoWatchState returns "watching", "muted", or "" for the default.
122func (s *Store) RepoWatchState(repoID, userID int64) string {
123 var state string
124 s.DB.QueryRow("SELECT state FROM repo_watchers WHERE repo_id = ? AND user_id = ?",
125 repoID, userID).Scan(&state)
126 return state
127}
128
129// NotifyRecipients is who actually hears about something on a repository:
130// the callers targets — owners, or a thread's participants — widened by
131// the repository's watchers and by accounts whose watch preference covers
132// it, minus the actor and minus anyone who muted it. Muting wins over
133// every other reason to be told, including owning the repository or
134// having written the thread.
135func (s *Store) NotifyRecipients(repoID, actorID int64, targets []int64, widen bool) ([]int64, error) {
136 rows, err := s.DB.Query("SELECT user_id, state FROM repo_watchers WHERE repo_id = ?", repoID)
137 if err != nil {
138 return nil, err
139 }
140 defer rows.Close()
141 skip := map[int64]bool{actorID: true}
142 var watching []int64
143 for rows.Next() {
144 var id int64
145 var state string
146 if err := rows.Scan(&id, &state); err != nil {
147 return nil, err
148 }
149 if state == "muted" {
150 skip[id] = true
151 } else {
152 watching = append(watching, id)
153 }
154 }
155 if err := rows.Err(); err != nil {
156 return nil, err
157 }
158 var out []int64
159 seen := map[int64]bool{}
160 if !widen {
161 watching = nil
162 } else {
163 byPref, err := s.defaultWatchers(repoID)
164 if err != nil {
165 return nil, err
166 }
167 watching = append(watching, byPref...)
168 }
169 for _, id := range append(append([]int64{}, targets...), watching...) {
170 if skip[id] || seen[id] {
171 continue
172 }
173 seen[id] = true
174 out = append(out, id)
175 }
176 return out, nil
177}
178
179// defaultWatchers is every account with the watch preference on that can
180// write to the repository (#194). The preference is read first because
181// it is one column and usually off everywhere, which keeps the access
182// fold off the path of most notices.
183func (s *Store) defaultWatchers(repoID int64) ([]int64, error) {
184 rows, err := s.DB.Query("SELECT id, username FROM users WHERE notify_watch = 1")
185 if err != nil {
186 return nil, err
187 }
188 defer rows.Close()
189 ids := map[string]int64{}
190 for rows.Next() {
191 var id int64
192 var name string
193 if err := rows.Scan(&id, &name); err != nil {
194 return nil, err
195 }
196 ids[name] = id
197 }
198 if err := rows.Err(); err != nil || len(ids) == 0 {
199 return nil, err
200 }
201 access, err := s.EffectiveAccess(repoID)
202 if err != nil {
203 return nil, err
204 }
205 var out []int64
206 for _, e := range access {
207 if id, ok := ids[e.Username]; ok && (e.Role == "write" || e.Role == "admin") {
208 out = append(out, id)
209 }
210 }
211 return out, nil
212}