1package db
2
3import (
4 "context"
5 "log"
6 "maps"
7 "slices"
8
9 "github.com/bluesky-social/indigo/atproto/syntax"
10 "tangled.org/core/appview/db"
11 "tangled.org/core/appview/models"
12 "tangled.org/core/appview/notify"
13 "tangled.org/core/idresolver"
14)
15
16type databaseNotifier struct {
17 db *db.DB
18 res *idresolver.Resolver
19}
20
21func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier {
22 return &databaseNotifier{
23 db: database,
24 res: resolver,
25 }
26}
27
28var _ notify.Notifier = &databaseNotifier{}
29
30func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
31 // no-op for now
32}
33
34func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
35 var err error
36 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
37 if err != nil {
38 log.Printf("NewStar: failed to get repos: %v", err)
39 return
40 }
41
42 actorDid := syntax.DID(star.StarredByDid)
43 recipients := []syntax.DID{syntax.DID(repo.Did)}
44 eventType := models.NotificationTypeRepoStarred
45 entityType := "repo"
46 entityId := star.RepoAt.String()
47 repoId := &repo.Id
48 var issueId *int64
49 var pullId *int64
50
51 n.notifyEvent(
52 actorDid,
53 recipients,
54 eventType,
55 entityType,
56 entityId,
57 repoId,
58 issueId,
59 pullId,
60 )
61}
62
63func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {
64 // no-op
65}
66
67func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
68
69 // build the recipients list
70 // - owner of the repo
71 // - collaborators in the repo
72 var recipients []syntax.DID
73 recipients = append(recipients, syntax.DID(issue.Repo.Did))
74 collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
75 if err != nil {
76 log.Printf("failed to fetch collaborators: %v", err)
77 return
78 }
79 for _, c := range collaborators {
80 recipients = append(recipients, c.SubjectDid)
81 }
82
83 actorDid := syntax.DID(issue.Did)
84 eventType := models.NotificationTypeIssueCreated
85 entityType := "issue"
86 entityId := issue.AtUri().String()
87 repoId := &issue.Repo.Id
88 issueId := &issue.Id
89 var pullId *int64
90
91 n.notifyEvent(
92 actorDid,
93 recipients,
94 eventType,
95 entityType,
96 entityId,
97 repoId,
98 issueId,
99 pullId,
100 )
101}
102
103func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
104 issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
105 if err != nil {
106 log.Printf("NewIssueComment: failed to get issues: %v", err)
107 return
108 }
109 if len(issues) == 0 {
110 log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt)
111 return
112 }
113 issue := issues[0]
114
115 var recipients []syntax.DID
116 recipients = append(recipients, syntax.DID(issue.Repo.Did))
117
118 if comment.IsReply() {
119 // if this comment is a reply, then notify everybody in that thread
120 parentAtUri := *comment.ReplyTo
121 allThreads := issue.CommentList()
122
123 // find the parent thread, and add all DIDs from here to the recipient list
124 for _, t := range allThreads {
125 if t.Self.AtUri().String() == parentAtUri {
126 recipients = append(recipients, t.Participants()...)
127 }
128 }
129 } else {
130 // not a reply, notify just the issue author
131 recipients = append(recipients, syntax.DID(issue.Did))
132 }
133
134 actorDid := syntax.DID(comment.Did)
135 eventType := models.NotificationTypeIssueCommented
136 entityType := "issue"
137 entityId := issue.AtUri().String()
138 repoId := &issue.Repo.Id
139 issueId := &issue.Id
140 var pullId *int64
141
142 n.notifyEvent(
143 actorDid,
144 recipients,
145 eventType,
146 entityType,
147 entityId,
148 repoId,
149 issueId,
150 pullId,
151 )
152}
153
154func (n *databaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {
155 // no-op for now
156}
157
158func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
159 actorDid := syntax.DID(follow.UserDid)
160 recipients := []syntax.DID{syntax.DID(follow.SubjectDid)}
161 eventType := models.NotificationTypeFollowed
162 entityType := "follow"
163 entityId := follow.UserDid
164 var repoId, issueId, pullId *int64
165
166 n.notifyEvent(
167 actorDid,
168 recipients,
169 eventType,
170 entityType,
171 entityId,
172 repoId,
173 issueId,
174 pullId,
175 )
176}
177
178func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
179 // no-op
180}
181
182func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
183 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
184 if err != nil {
185 log.Printf("NewPull: failed to get repos: %v", err)
186 return
187 }
188
189 // build the recipients list
190 // - owner of the repo
191 // - collaborators in the repo
192 var recipients []syntax.DID
193 recipients = append(recipients, syntax.DID(repo.Did))
194 collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
195 if err != nil {
196 log.Printf("failed to fetch collaborators: %v", err)
197 return
198 }
199 for _, c := range collaborators {
200 recipients = append(recipients, c.SubjectDid)
201 }
202
203 actorDid := syntax.DID(pull.OwnerDid)
204 eventType := models.NotificationTypePullCreated
205 entityType := "pull"
206 entityId := pull.AtUri().String()
207 repoId := &repo.Id
208 var issueId *int64
209 p := int64(pull.ID)
210 pullId := &p
211
212 n.notifyEvent(
213 actorDid,
214 recipients,
215 eventType,
216 entityType,
217 entityId,
218 repoId,
219 issueId,
220 pullId,
221 )
222}
223
224func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
225 pull, err := db.GetPull(n.db,
226 syntax.ATURI(comment.RepoAt),
227 comment.PullId,
228 )
229 if err != nil {
230 log.Printf("NewPullComment: failed to get pulls: %v", err)
231 return
232 }
233
234 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
235 if err != nil {
236 log.Printf("NewPullComment: failed to get repos: %v", err)
237 return
238 }
239
240 // build up the recipients list:
241 // - repo owner
242 // - all pull participants
243 var recipients []syntax.DID
244 recipients = append(recipients, syntax.DID(repo.Did))
245 for _, p := range pull.Participants() {
246 recipients = append(recipients, syntax.DID(p))
247 }
248
249 actorDid := syntax.DID(comment.OwnerDid)
250 eventType := models.NotificationTypePullCommented
251 entityType := "pull"
252 entityId := pull.AtUri().String()
253 repoId := &repo.Id
254 var issueId *int64
255 p := int64(pull.ID)
256 pullId := &p
257
258 n.notifyEvent(
259 actorDid,
260 recipients,
261 eventType,
262 entityType,
263 entityId,
264 repoId,
265 issueId,
266 pullId,
267 )
268}
269
270func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
271 // no-op
272}
273
274func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) {
275 // no-op
276}
277
278func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) {
279 // no-op
280}
281
282func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) {
283 // no-op
284}
285
286func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
287 // build up the recipients list:
288 // - repo owner
289 // - repo collaborators
290 // - all issue participants
291 var recipients []syntax.DID
292 recipients = append(recipients, syntax.DID(issue.Repo.Did))
293 collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
294 if err != nil {
295 log.Printf("failed to fetch collaborators: %v", err)
296 return
297 }
298 for _, c := range collaborators {
299 recipients = append(recipients, c.SubjectDid)
300 }
301 for _, p := range issue.Participants() {
302 recipients = append(recipients, syntax.DID(p))
303 }
304
305 entityType := "pull"
306 entityId := issue.AtUri().String()
307 repoId := &issue.Repo.Id
308 issueId := &issue.Id
309 var pullId *int64
310 var eventType models.NotificationType
311
312 if issue.Open {
313 eventType = models.NotificationTypeIssueReopen
314 } else {
315 eventType = models.NotificationTypeIssueClosed
316 }
317
318 n.notifyEvent(
319 actor,
320 recipients,
321 eventType,
322 entityType,
323 entityId,
324 repoId,
325 issueId,
326 pullId,
327 )
328}
329
330func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
331 // Get repo details
332 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
333 if err != nil {
334 log.Printf("NewPullState: failed to get repos: %v", err)
335 return
336 }
337
338 // build up the recipients list:
339 // - repo owner
340 // - all pull participants
341 var recipients []syntax.DID
342 recipients = append(recipients, syntax.DID(repo.Did))
343 collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
344 if err != nil {
345 log.Printf("failed to fetch collaborators: %v", err)
346 return
347 }
348 for _, c := range collaborators {
349 recipients = append(recipients, c.SubjectDid)
350 }
351 for _, p := range pull.Participants() {
352 recipients = append(recipients, syntax.DID(p))
353 }
354
355 entityType := "pull"
356 entityId := pull.AtUri().String()
357 repoId := &repo.Id
358 var issueId *int64
359 var eventType models.NotificationType
360 switch pull.State {
361 case models.PullClosed:
362 eventType = models.NotificationTypePullClosed
363 case models.PullOpen:
364 eventType = models.NotificationTypePullReopen
365 case models.PullMerged:
366 eventType = models.NotificationTypePullMerged
367 default:
368 log.Println("NewPullState: unexpected new PR state:", pull.State)
369 return
370 }
371 p := int64(pull.ID)
372 pullId := &p
373
374 n.notifyEvent(
375 actor,
376 recipients,
377 eventType,
378 entityType,
379 entityId,
380 repoId,
381 issueId,
382 pullId,
383 )
384}
385
386func (n *databaseNotifier) notifyEvent(
387 actorDid syntax.DID,
388 recipients []syntax.DID,
389 eventType models.NotificationType,
390 entityType string,
391 entityId string,
392 repoId *int64,
393 issueId *int64,
394 pullId *int64,
395) {
396 recipientSet := make(map[syntax.DID]struct{})
397 for _, did := range recipients {
398 // everybody except actor themselves
399 if did != actorDid {
400 recipientSet[did] = struct{}{}
401 }
402 }
403
404 prefMap, err := db.GetNotificationPreferences(
405 n.db,
406 db.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))),
407 )
408 if err != nil {
409 // failed to get prefs for users
410 return
411 }
412
413 // create a transaction for bulk notification storage
414 tx, err := n.db.Begin()
415 if err != nil {
416 // failed to start tx
417 return
418 }
419 defer tx.Rollback()
420
421 // filter based on preferences
422 for recipientDid := range recipientSet {
423 prefs, ok := prefMap[recipientDid]
424 if !ok {
425 prefs = models.DefaultNotificationPreferences(recipientDid)
426 }
427
428 // skip users who don’t want this type
429 if !prefs.ShouldNotify(eventType) {
430 continue
431 }
432
433 // create notification
434 notif := &models.Notification{
435 RecipientDid: recipientDid.String(),
436 ActorDid: actorDid.String(),
437 Type: eventType,
438 EntityType: entityType,
439 EntityId: entityId,
440 RepoId: repoId,
441 IssueId: issueId,
442 PullId: pullId,
443 }
444
445 if err := db.CreateNotification(tx, notif); err != nil {
446 log.Printf("notifyEvent: failed to create notification for %s: %v", recipientDid, err)
447 }
448 }
449
450 if err := tx.Commit(); err != nil {
451 // failed to commit
452 return
453 }
454}