package db import ( "context" "slices" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/appview/notify" "tangled.org/core/idresolver" "tangled.org/core/log" "tangled.org/core/orm" "tangled.org/core/sets" ) const ( maxMentions = 8 assigneeLabelAt = "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee" ) type databaseNotifier struct { db *db.DB res *idresolver.Resolver } func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier { return &databaseNotifier{ db: database, res: resolver, } } var _ notify.Notifier = &databaseNotifier{} func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) { // no-op for now } func (n *databaseNotifier) DeleteRepo(ctx context.Context, repo *models.Repo) { // no-op for now } func (n *databaseNotifier) RenameRepo(ctx context.Context, actor syntax.DID, oldRepo, newRepo *models.Repo) { } func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { l := log.FromContext(ctx) if star.SubjectType != models.StarSubjectRepo { return } repo, err := db.GetRepo(n.db, orm.FilterEq("repo_did", star.Subject)) if err != nil { l.Error("failed to get repos", "err", err) return } actorDid := syntax.DID(star.Did) recipients := sets.Singleton(syntax.DID(repo.Did)) eventType := models.NotificationTypeRepoStarred entityType := "repo" entityId := star.Subject repoId := &repo.Id var issueId *int64 var pullId *int64 n.notifyEvent( ctx, actorDid, recipients, eventType, entityType, entityId, repoId, issueId, pullId, ) } func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { // no-op } func (n *databaseNotifier) NewComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { l := log.FromContext(ctx) var ( // built the recipients list: // - the owner of the repo // - | if the comment is a reply -> everybody on that thread // | if the comment is a top level -> just the issue owner // - remove mentioned users from the recipients list recipients = sets.New[syntax.DID]() entityType string entityId string repoId *int64 issueId *int64 pullId *int64 ) subjectAt := syntax.ATURI(comment.Subject.Uri) switch subjectAt.Collection() { case tangled.RepoIssueNSID: issues, err := db.GetIssues( n.db, orm.FilterEq("at_uri", subjectAt), ) if err != nil { l.Error("failed to get issues", "err", err) return } if len(issues) == 0 { l.Error("no issue found", "subject", comment.Subject) return } issue := issues[0] recipients.Insert(syntax.DID(issue.Repo.Did)) if comment.IsReply() { // if this comment is a reply, then notify everybody in that thread parent := *comment.ReplyTo // find the parent thread, and add all DIDs from here to the recipient list for _, t := range models.NewCommentList(issue.Comments) { if t.Self.AtUri() == syntax.ATURI(parent.Uri) { for _, p := range t.Participants() { recipients.Insert(p) } } } } else { // not a reply, notify just the issue author recipients.Insert(syntax.DID(issue.Did)) } entityType = "issue" entityId = issue.AtUri().String() repoId = &issue.Repo.Id issueId = &issue.Id for _, m := range mentions { recipients.Remove(m) } n.notifyEvent( ctx, comment.Did, recipients, models.NotificationTypeIssueCommented, entityType, entityId, repoId, issueId, pullId, ) case tangled.RepoPullNSID: pull, err := db.GetPull( n.db, orm.FilterEq("owner_did", subjectAt.Authority()), orm.FilterEq("rkey", subjectAt.RecordKey()), ) if err != nil { l.Error("NewComment: failed to get pull", "err", err) return } pull.Repo, err = db.GetRepo(n.db, orm.FilterEq("repo_did", pull.RepoDid)) if err != nil { l.Error("NewComment: failed to get repo", "err", err) return } recipients.Insert(syntax.DID(pull.Repo.Did)) for _, p := range pull.Participants() { recipients.Insert(syntax.DID(p)) } entityType = "pull" entityId = pull.AtUri().String() repoId = &pull.Repo.Id p := int64(pull.ID) pullId = &p for _, m := range mentions { recipients.Remove(m) } n.notifyEvent( ctx, comment.Did, recipients, models.NotificationTypePullCommented, entityType, entityId, repoId, issueId, pullId, ) default: return // no-op } n.notifyEvent( ctx, comment.Did, sets.Collect(slices.Values(mentions)), models.NotificationTypeUserMentioned, entityType, entityId, repoId, issueId, pullId, ) } func (n *databaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { // no-op } func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { l := log.FromContext(ctx) collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_did", string(issue.RepoDid))) if err != nil { l.Error("failed to fetch collaborators", "err", err) return } // build the recipients list // - owner of the repo // - collaborators in the repo // - remove users already mentioned recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) for _, c := range collaborators { recipients.Insert(c.SubjectDid) } for _, m := range mentions { recipients.Remove(m) } actorDid := syntax.DID(issue.Did) entityType := "issue" entityId := issue.AtUri().String() repoId := &issue.Repo.Id issueId := &issue.Id var pullId *int64 n.notifyEvent( ctx, actorDid, recipients, models.NotificationTypeIssueCreated, entityType, entityId, repoId, issueId, pullId, ) n.notifyEvent( ctx, actorDid, sets.Collect(slices.Values(mentions)), models.NotificationTypeUserMentioned, entityType, entityId, repoId, issueId, pullId, ) } func (n *databaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) { // no-op for now } func (n *databaseNotifier) NewIssueLabelOp(ctx context.Context, actor syntax.DID, issue *models.Issue, ops []models.LabelOp) { entityType := "issue" entityId := issue.AtUri().String() repoId := &issue.Repo.Id issueId := &issue.Id var pullId *int64 assigned := sets.New[syntax.DID]() unassigned := sets.New[syntax.DID]() for _, op := range ops { if op.OperandKey != assigneeLabelAt { continue } assignee := syntax.DID(op.OperandValue) switch op.Operation { case models.LabelOperationAdd: assigned.Insert(assignee) case models.LabelOperationDel: unassigned.Insert(assignee) default: continue } } n.notifyEvent(ctx, actor, assigned, models.NotificationTypeIssueAssigned, entityType, entityId, repoId, issueId, pullId) n.notifyEvent(ctx, actor, unassigned, models.NotificationTypeIssueUnassigned, entityType, entityId, repoId, issueId, pullId) } func (n *databaseNotifier) NewPullLabelOp(ctx context.Context, actor syntax.DID, pull *models.Pull, ops []models.LabelOp) { entityType := "pull" entityId := pull.AtUri().String() repoId := &pull.Repo.Id var issueId *int64 p := int64(pull.ID) pullId := &p assigned := sets.New[syntax.DID]() unassigned := sets.New[syntax.DID]() for _, op := range ops { if op.OperandKey != assigneeLabelAt { continue } assignee := syntax.DID(op.OperandValue) switch op.Operation { case models.LabelOperationAdd: assigned.Insert(assignee) case models.LabelOperationDel: unassigned.Insert(assignee) default: continue } } n.notifyEvent(ctx, actor, assigned, models.NotificationTypePullAssigned, entityType, entityId, repoId, issueId, pullId) n.notifyEvent(ctx, actor, unassigned, models.NotificationTypePullUnassigned, entityType, entityId, repoId, issueId, pullId) } func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { actorDid := syntax.DID(follow.UserDid) recipients := sets.Singleton(syntax.DID(follow.SubjectDid)) eventType := models.NotificationTypeFollowed entityType := "follow" entityId := follow.UserDid var repoId, issueId, pullId *int64 n.notifyEvent( ctx, actorDid, recipients, eventType, entityType, entityId, repoId, issueId, pullId, ) } func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { // no-op } func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { l := log.FromContext(ctx) repo, err := db.GetRepo(n.db, orm.FilterEq("repo_did", string(pull.RepoDid))) if err != nil { l.Error("failed to get repos", "err", err) return } collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_did", string(pull.RepoDid))) if err != nil { l.Error("failed to fetch collaborators", "err", err) return } // build the recipients list // - owner of the repo // - collaborators in the repo recipients := sets.Singleton(syntax.DID(repo.Did)) for _, c := range collaborators { recipients.Insert(c.SubjectDid) } actorDid := syntax.DID(pull.OwnerDid) eventType := models.NotificationTypePullCreated entityType := "pull" entityId := pull.AtUri().String() repoId := &repo.Id var issueId *int64 p := int64(pull.ID) pullId := &p n.notifyEvent( ctx, actorDid, recipients, eventType, entityType, entityId, repoId, issueId, pullId, ) } func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { // no-op } func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) { // no-op } func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) { // no-op } func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) { // no-op } func (n *databaseNotifier) Push(ctx context.Context, repo *models.Repo, ref, oldSha, newSha, committerDid string) { // no-op for now; webhooks are handled by the webhook notifier } func (n *databaseNotifier) Clone(ctx context.Context, repo *models.Repo) { // no-op } func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { l := log.FromContext(ctx) collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_did", string(issue.RepoDid))) if err != nil { l.Error("failed to fetch collaborators", "err", err) return } // build up the recipients list: // - repo owner // - repo collaborators // - all issue participants recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) for _, c := range collaborators { recipients.Insert(c.SubjectDid) } for _, p := range issue.Participants() { recipients.Insert(syntax.DID(p)) } entityType := "issue" entityId := issue.AtUri().String() repoId := &issue.Repo.Id issueId := &issue.Id var pullId *int64 var eventType models.NotificationType if issue.Open { eventType = models.NotificationTypeIssueReopen } else { eventType = models.NotificationTypeIssueClosed } n.notifyEvent( ctx, actor, recipients, eventType, entityType, entityId, repoId, issueId, pullId, ) } func (n *databaseNotifier) ResubmitPull(ctx context.Context, pull *models.Pull) { // no-op for now } func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { l := log.FromContext(ctx) // Get repo details repo, err := db.GetRepo(n.db, orm.FilterEq("repo_did", string(pull.RepoDid))) if err != nil { l.Error("failed to get repos", "err", err) return } collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_did", string(pull.RepoDid))) if err != nil { l.Error("failed to fetch collaborators", "err", err) return } // build up the recipients list: // - repo owner // - all pull participants recipients := sets.Singleton(syntax.DID(repo.Did)) for _, c := range collaborators { recipients.Insert(c.SubjectDid) } for _, p := range pull.Participants() { recipients.Insert(p) } entityType := "pull" entityId := pull.AtUri().String() repoId := &repo.Id var issueId *int64 var eventType models.NotificationType switch pull.State { case models.PullClosed: eventType = models.NotificationTypePullClosed case models.PullOpen: eventType = models.NotificationTypePullReopen case models.PullMerged: eventType = models.NotificationTypePullMerged default: l.Error("unexpected new PR state", "state", pull.State) return } p := int64(pull.ID) pullId := &p n.notifyEvent( ctx, actor, recipients, eventType, entityType, entityId, repoId, issueId, pullId, ) } func (n *databaseNotifier) notifyEvent( ctx context.Context, actorDid syntax.DID, recipients sets.Set[syntax.DID], eventType models.NotificationType, entityType string, entityId string, repoId *int64, issueId *int64, pullId *int64, ) { l := log.FromContext(ctx) // if the user is attempting to mention >maxMentions users, this is probably spam, do not mention anybody if eventType == models.NotificationTypeUserMentioned && recipients.Len() > maxMentions { return } // Apply subscription overrides for thread-activity events only. // Mention and assignment events are targeted at specific users and should // not be broadcast to all thread subscribers. isThreadEvent := eventType != models.NotificationTypeUserMentioned && eventType != models.NotificationTypeIssueAssigned && eventType != models.NotificationTypeIssueUnassigned && eventType != models.NotificationTypePullAssigned && eventType != models.NotificationTypePullUnassigned switch { case issueId != nil && isThreadEvent: if subs, err := db.GetIssueSubscribers(n.db, *issueId); err == nil { for _, did := range subs { recipients.Insert(syntax.DID(did)) } } if unsubs, err := db.GetIssueUnsubscribers(n.db, *issueId); err == nil { for _, did := range unsubs { recipients.Remove(syntax.DID(did)) } } case pullId != nil && isThreadEvent: if subs, err := db.GetPullSubscribers(n.db, *pullId); err == nil { for _, did := range subs { recipients.Insert(syntax.DID(did)) } } if unsubs, err := db.GetPullUnsubscribers(n.db, *pullId); err == nil { for _, did := range unsubs { recipients.Remove(syntax.DID(did)) } } } recipients.Remove(actorDid) // Auto-subscribe the actor to this issue/pull when they interact with it. // This happens outside the transaction since it’s best-effort. switch { case issueId != nil: if err := db.UpsertIssueSubscription(n.db, actorDid.String(), *issueId, true); err != nil { l.Warn("failed to auto-subscribe actor to issue", "actor", actorDid, "issueId", *issueId, "err", err) } case pullId != nil: if err := db.UpsertPullSubscription(n.db, actorDid.String(), *pullId, true); err != nil { l.Warn("failed to auto-subscribe actor to pull", "actor", actorDid, "pullId", *pullId, "err", err) } } prefMap, err := db.GetNotificationPreferences( n.db, orm.FilterIn("user_did", slices.Collect(recipients.All())), ) if err != nil { // failed to get prefs for users return } // create a transaction for bulk notification storage tx, err := n.db.Begin() if err != nil { // failed to start tx return } defer tx.Rollback() // filter based on preferences for recipientDid := range recipients.All() { prefs, ok := prefMap[recipientDid] if !ok { prefs = models.DefaultNotificationPreferences(recipientDid) } // skip users who don’t want this type if !prefs.ShouldNotify(eventType) { continue } // create notification notif := &models.Notification{ RecipientDid: recipientDid.String(), ActorDid: actorDid.String(), Type: eventType, EntityType: entityType, EntityId: entityId, RepoId: repoId, IssueId: issueId, PullId: pullId, } if err := db.CreateNotification(tx, notif); err != nil { l.Error("failed to create notification", "recipientDid", recipientDid, "err", err) } } if err := tx.Commit(); err != nil { // failed to commit return } }