···701701 updated text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
702702 );
703703704704+ create table if not exists issue_subscriptions (
705705+ user_did text not null,
706706+ issue_id integer not null references issues(id) on delete cascade,
707707+ subscribed integer not null default 1,
708708+ primary key(user_did, issue_id)
709709+ );
710710+711711+ create table if not exists pull_subscriptions (
712712+ user_did text not null,
713713+ pull_id integer not null references pulls(id) on delete cascade,
714714+ subscribed integer not null default 1,
715715+ primary key(user_did, pull_id)
716716+ );
717717+704718 create table if not exists migrations (
705719 id integer primary key autoincrement,
706720 name text unique
···713727 create index if not exists idx_references_to_at on reference_links(to_at);
714728 create index if not exists idx_webhook_deliveries_webhook_id on webhook_deliveries(webhook_id);
715729 create index if not exists idx_newsletter_prefs_user_did on newsletter_preferences(user_did);
730730+ create index if not exists idx_issue_subscriptions_issue on issue_subscriptions(issue_id, subscribed);
731731+ create index if not exists idx_pull_subscriptions_pull on pull_subscriptions(pull_id, subscribed);
716732 `)
717733 if err != nil {
718734 return nil, err
···24412457 return err
24422458 })
2443245924602460+ orm.RunMigration(conn, logger, "add-emailed-to-notifications", func(tx *sql.Tx) error {
24612461+ _, err := tx.Exec(`
24622462+ ALTER TABLE notifications ADD COLUMN emailed INTEGER NOT NULL DEFAULT 0;
24632463+ CREATE INDEX IF NOT EXISTS idx_notifications_emailed
24642464+ ON notifications(recipient_did, emailed, created);
24652465+ `)
24662466+ return err
24672467+ })
24442468 return &DB{
24452469 db,
24462470 logger,
+196
appview/db/notifications.go
···550550 return nil
551551}
552552553553+// GetPendingEmailDigestRecipients returns DIDs of users who have email
554554+// notifications enabled, have a verified primary email, and have unemaileD
555555+// notifications older than olderThan for email-eligible notification types.
556556+func GetPendingEmailDigestRecipients(e Execer, olderThan time.Time) ([]string, error) {
557557+ placeholders := make([]string, len(models.EmailNotificationTypes))
558558+ args := []any{olderThan.UTC().Format(time.RFC3339)}
559559+ for i, t := range models.EmailNotificationTypes {
560560+ placeholders[i] = "?"
561561+ args = append(args, string(t))
562562+ }
563563+ inClause := strings.Join(placeholders, ", ")
564564+565565+ query := fmt.Sprintf(`
566566+ SELECT DISTINCT n.recipient_did
567567+ FROM notifications n
568568+ JOIN notification_preferences np ON np.user_did = n.recipient_did
569569+ JOIN emails e ON e.did = n.recipient_did AND e.is_primary = 1 AND e.verified = 1
570570+ WHERE n.emailed = 0
571571+ AND n.created < ?
572572+ AND np.email_notifications = 1
573573+ AND n.type IN (%s)
574574+ `, inClause)
575575+576576+ rows, err := e.Query(query, args...)
577577+ if err != nil {
578578+ return nil, fmt.Errorf("failed to query email digest recipients: %w", err)
579579+ }
580580+ defer rows.Close()
581581+582582+ var dids []string
583583+ for rows.Next() {
584584+ var did string
585585+ if err := rows.Scan(&did); err != nil {
586586+ return nil, err
587587+ }
588588+ dids = append(dids, did)
589589+ }
590590+ return dids, rows.Err()
591591+}
592592+593593+// GetPendingNotificationsForEmailDigest returns all unemailed notifications
594594+// older than olderThan for email-eligible types for a specific recipient.
595595+func GetPendingNotificationsForEmailDigest(e Execer, recipientDid string, olderThan time.Time) ([]*models.NotificationWithEntity, error) {
596596+ placeholders := make([]string, len(models.EmailNotificationTypes))
597597+ args := []any{recipientDid, olderThan.UTC().Format(time.RFC3339)}
598598+ for i, t := range models.EmailNotificationTypes {
599599+ placeholders[i] = "?"
600600+ args = append(args, string(t))
601601+ }
602602+ inClause := strings.Join(placeholders, ", ")
603603+604604+ query := fmt.Sprintf(`
605605+ SELECT
606606+ n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
607607+ n.read, n.created, n.repo_id, n.issue_id, n.pull_id,
608608+ r.id as r_id, r.did as r_did, r.rkey as r_rkey, r.name as r_name, r.description as r_description, r.website as r_website, r.topics as r_topics,
609609+ i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open,
610610+ p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state
611611+ FROM notifications n
612612+ LEFT JOIN repos r ON n.repo_id = r.id
613613+ LEFT JOIN issues i ON n.issue_id = i.id
614614+ LEFT JOIN pulls p ON n.pull_id = p.id
615615+ WHERE n.recipient_did = ?
616616+ AND n.emailed = 0
617617+ AND n.created < ?
618618+ AND n.type IN (%s)
619619+ ORDER BY n.created DESC
620620+ `, inClause)
621621+622622+ rows, err := e.QueryContext(context.Background(), query, args...)
623623+ if err != nil {
624624+ return nil, fmt.Errorf("failed to query pending email notifications: %w", err)
625625+ }
626626+ defer rows.Close()
627627+628628+ var notifications []*models.NotificationWithEntity
629629+ for rows.Next() {
630630+ var n models.Notification
631631+ var typeStr string
632632+ var createdStr string
633633+ var repo models.Repo
634634+ var issue models.Issue
635635+ var pull models.Pull
636636+ var rId, iId, pId sql.NullInt64
637637+ var rDid, rRkey, rName, rDescription, rWebsite, rTopicStr sql.NullString
638638+ var iDid sql.NullString
639639+ var iIssueId sql.NullInt64
640640+ var iTitle sql.NullString
641641+ var iOpen sql.NullBool
642642+ var pOwnerDid sql.NullString
643643+ var pPullId sql.NullInt64
644644+ var pTitle sql.NullString
645645+ var pState sql.NullInt64
646646+647647+ err := rows.Scan(
648648+ &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
649649+ &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
650650+ &rId, &rDid, &rRkey, &rName, &rDescription, &rWebsite, &rTopicStr,
651651+ &iId, &iDid, &iIssueId, &iTitle, &iOpen,
652652+ &pId, &pOwnerDid, &pPullId, &pTitle, &pState,
653653+ )
654654+ if err != nil {
655655+ return nil, fmt.Errorf("failed to scan email digest notification: %w", err)
656656+ }
657657+658658+ n.Type = models.NotificationType(typeStr)
659659+ n.Created, err = time.Parse(time.RFC3339, createdStr)
660660+ if err != nil {
661661+ return nil, fmt.Errorf("failed to parse created timestamp: %w", err)
662662+ }
663663+664664+ entry := &models.NotificationWithEntity{Notification: &n}
665665+666666+ if rId.Valid {
667667+ repo.Id = rId.Int64
668668+ if rDid.Valid {
669669+ repo.Did = rDid.String
670670+ }
671671+ if rRkey.Valid {
672672+ repo.Rkey = rRkey.String
673673+ }
674674+ if rName.Valid {
675675+ repo.Name = rName.String
676676+ }
677677+ if rDescription.Valid {
678678+ repo.Description = rDescription.String
679679+ }
680680+ if rWebsite.Valid {
681681+ repo.Website = rWebsite.String
682682+ }
683683+ if rTopicStr.Valid {
684684+ repo.Topics = strings.Fields(rTopicStr.String)
685685+ }
686686+ entry.Repo = &repo
687687+ }
688688+689689+ if iId.Valid {
690690+ issue.Id = iId.Int64
691691+ if iDid.Valid {
692692+ issue.Did = iDid.String
693693+ }
694694+ if iIssueId.Valid {
695695+ issue.IssueId = int(iIssueId.Int64)
696696+ }
697697+ if iTitle.Valid {
698698+ issue.Title = iTitle.String
699699+ }
700700+ if iOpen.Valid {
701701+ issue.Open = iOpen.Bool
702702+ }
703703+ entry.Issue = &issue
704704+ }
705705+706706+ if pId.Valid {
707707+ pull.ID = int(pId.Int64)
708708+ if pOwnerDid.Valid {
709709+ pull.OwnerDid = pOwnerDid.String
710710+ }
711711+ if pPullId.Valid {
712712+ pull.PullId = int(pPullId.Int64)
713713+ }
714714+ if pTitle.Valid {
715715+ pull.Title = pTitle.String
716716+ }
717717+ if pState.Valid {
718718+ pull.State = models.PullState(pState.Int64)
719719+ }
720720+ entry.Pull = &pull
721721+ }
722722+723723+ notifications = append(notifications, entry)
724724+ }
725725+726726+ return notifications, rows.Err()
727727+}
728728+729729+// MarkNotificationsEmailed marks the given notification IDs as emailed=1.
730730+// Uses explicit IDs (not recipient_did) to avoid racing with new notifications.
731731+func MarkNotificationsEmailed(e Execer, ids []int64) error {
732732+ if len(ids) == 0 {
733733+ return nil
734734+ }
735735+ placeholders := make([]string, len(ids))
736736+ args := make([]any, len(ids))
737737+ for i, id := range ids {
738738+ placeholders[i] = "?"
739739+ args[i] = id
740740+ }
741741+ query := fmt.Sprintf(
742742+ `UPDATE notifications SET emailed = 1 WHERE id IN (%s)`,
743743+ strings.Join(placeholders, ", "),
744744+ )
745745+ _, err := e.Exec(query, args...)
746746+ return err
747747+}
748748+553749func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error {
554750 cutoff := time.Now().Add(-olderThan)
555751 createdFilter := orm.FilterLte("created", cutoff)
+149
appview/db/subscriptions.go
···11+package db
22+33+import "database/sql"
44+55+// UpsertIssueSubscription inserts or updates a user's subscription for an issue.
66+// subscribed=true means they want notifications; subscribed=false means explicitly unsubscribed.
77+func UpsertIssueSubscription(e Execer, userDid string, issueId int64, subscribed bool) error {
88+ sub := 0
99+ if subscribed {
1010+ sub = 1
1111+ }
1212+ _, err := e.Exec(`
1313+ INSERT INTO issue_subscriptions (user_did, issue_id, subscribed)
1414+ VALUES (?, ?, ?)
1515+ ON CONFLICT(user_did, issue_id) DO UPDATE SET subscribed = excluded.subscribed
1616+ `, userDid, issueId, sub)
1717+ return err
1818+}
1919+2020+// UpsertPullSubscription inserts or updates a user's subscription for a pull.
2121+func UpsertPullSubscription(e Execer, userDid string, pullId int64, subscribed bool) error {
2222+ sub := 0
2323+ if subscribed {
2424+ sub = 1
2525+ }
2626+ _, err := e.Exec(`
2727+ INSERT INTO pull_subscriptions (user_did, pull_id, subscribed)
2828+ VALUES (?, ?, ?)
2929+ ON CONFLICT(user_did, pull_id) DO UPDATE SET subscribed = excluded.subscribed
3030+ `, userDid, pullId, sub)
3131+ return err
3232+}
3333+3434+// GetIssueSubscription returns (subscribed, found, err).
3535+// If no row exists, found=false (meaning no explicit subscription).
3636+func GetIssueSubscription(e Execer, userDid string, issueId int64) (subscribed bool, found bool, err error) {
3737+ var sub int
3838+ err = e.QueryRow(`
3939+ SELECT subscribed FROM issue_subscriptions
4040+ WHERE user_did = ? AND issue_id = ?
4141+ `, userDid, issueId).Scan(&sub)
4242+ if err == sql.ErrNoRows {
4343+ return false, false, nil
4444+ }
4545+ if err != nil {
4646+ return false, false, err
4747+ }
4848+ return sub == 1, true, nil
4949+}
5050+5151+// GetPullSubscription returns (subscribed, found, err).
5252+func GetPullSubscription(e Execer, userDid string, pullId int64) (subscribed bool, found bool, err error) {
5353+ var sub int
5454+ err = e.QueryRow(`
5555+ SELECT subscribed FROM pull_subscriptions
5656+ WHERE user_did = ? AND pull_id = ?
5757+ `, userDid, pullId).Scan(&sub)
5858+ if err == sql.ErrNoRows {
5959+ return false, false, nil
6060+ }
6161+ if err != nil {
6262+ return false, false, err
6363+ }
6464+ return sub == 1, true, nil
6565+}
6666+6767+// GetIssueSubscribers returns DIDs with subscribed=1 for an issue.
6868+func GetIssueSubscribers(e Execer, issueId int64) ([]string, error) {
6969+ rows, err := e.Query(`
7070+ SELECT user_did FROM issue_subscriptions
7171+ WHERE issue_id = ? AND subscribed = 1
7272+ `, issueId)
7373+ if err != nil {
7474+ return nil, err
7575+ }
7676+ defer rows.Close()
7777+ var dids []string
7878+ for rows.Next() {
7979+ var did string
8080+ if err := rows.Scan(&did); err != nil {
8181+ return nil, err
8282+ }
8383+ dids = append(dids, did)
8484+ }
8585+ return dids, rows.Err()
8686+}
8787+8888+// GetIssueUnsubscribers returns DIDs with subscribed=0 for an issue (opted out).
8989+func GetIssueUnsubscribers(e Execer, issueId int64) ([]string, error) {
9090+ rows, err := e.Query(`
9191+ SELECT user_did FROM issue_subscriptions
9292+ WHERE issue_id = ? AND subscribed = 0
9393+ `, issueId)
9494+ if err != nil {
9595+ return nil, err
9696+ }
9797+ defer rows.Close()
9898+ var dids []string
9999+ for rows.Next() {
100100+ var did string
101101+ if err := rows.Scan(&did); err != nil {
102102+ return nil, err
103103+ }
104104+ dids = append(dids, did)
105105+ }
106106+ return dids, rows.Err()
107107+}
108108+109109+// GetPullSubscribers returns DIDs with subscribed=1 for a pull.
110110+func GetPullSubscribers(e Execer, pullId int64) ([]string, error) {
111111+ rows, err := e.Query(`
112112+ SELECT user_did FROM pull_subscriptions
113113+ WHERE pull_id = ? AND subscribed = 1
114114+ `, pullId)
115115+ if err != nil {
116116+ return nil, err
117117+ }
118118+ defer rows.Close()
119119+ var dids []string
120120+ for rows.Next() {
121121+ var did string
122122+ if err := rows.Scan(&did); err != nil {
123123+ return nil, err
124124+ }
125125+ dids = append(dids, did)
126126+ }
127127+ return dids, rows.Err()
128128+}
129129+130130+// GetPullUnsubscribers returns DIDs with subscribed=0 for a pull (opted out).
131131+func GetPullUnsubscribers(e Execer, pullId int64) ([]string, error) {
132132+ rows, err := e.Query(`
133133+ SELECT user_did FROM pull_subscriptions
134134+ WHERE pull_id = ? AND subscribed = 0
135135+ `, pullId)
136136+ if err != nil {
137137+ return nil, err
138138+ }
139139+ defer rows.Close()
140140+ var dids []string
141141+ for rows.Next() {
142142+ var did string
143143+ if err := rows.Scan(&did); err != nil {
144144+ return nil, err
145145+ }
146146+ dids = append(dids, did)
147147+ }
148148+ return dids, rows.Err()
149149+}
+20
appview/models/notifications.go
···6060 EntityType string
6161 EntityId string
6262 Read bool
6363+ Emailed bool
6364 Created time.Time
64656566 // foreign key references
6667 RepoId *int64
6768 IssueId *int64
6869 PullId *int64
7070+}
7171+7272+// EmailNotificationTypes are the notification types that are included in email digests.
7373+// Social types (repo_starred, followed) are intentionally excluded.
7474+var EmailNotificationTypes = []NotificationType{
7575+ NotificationTypeIssueCreated,
7676+ NotificationTypeIssueCommented,
7777+ NotificationTypeIssueClosed,
7878+ NotificationTypeIssueReopen,
7979+ NotificationTypePullCreated,
8080+ NotificationTypePullCommented,
8181+ NotificationTypePullMerged,
8282+ NotificationTypePullClosed,
8383+ NotificationTypePullReopen,
8484+ NotificationTypeUserMentioned,
8585+ NotificationTypeIssueAssigned,
8686+ NotificationTypeIssueUnassigned,
8787+ NotificationTypePullAssigned,
8888+ NotificationTypePullUnassigned,
6989}
70907191// lucide icon that represents this notification