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