···530530 unique (repo_at, label_at)
531531 );
532532533533+ create table if not exists notifications (
534534+ id integer primary key autoincrement,
535535+ recipient_did text not null,
536536+ actor_did text not null,
537537+ type text not null,
538538+ entity_type text not null,
539539+ entity_id text not null,
540540+ read integer not null default 0,
541541+ created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
542542+ repo_id integer references repos(id),
543543+ issue_id integer references issues(id),
544544+ pull_id integer references pulls(id)
545545+ );
546546+547547+ create table if not exists notification_preferences (
548548+ id integer primary key autoincrement,
549549+ user_did text not null unique,
550550+ repo_starred integer not null default 1,
551551+ issue_created integer not null default 1,
552552+ issue_commented integer not null default 1,
553553+ pull_created integer not null default 1,
554554+ pull_commented integer not null default 1,
555555+ followed integer not null default 1,
556556+ pull_merged integer not null default 1,
557557+ issue_closed integer not null default 1,
558558+ email_notifications integer not null default 0
559559+ );
560560+533561 create table if not exists migrations (
534562 id integer primary key autoincrement,
535563 name text unique
536564 );
537565538538- -- indexes for better star query performance
566566+ -- indexes for better performance
567567+ create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
568568+ create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
539569 create index if not exists idx_stars_created on stars(created);
540570 create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
541571 `)
+457
appview/db/notifications.go
···11+package db
22+33+import (
44+ "context"
55+ "database/sql"
66+ "fmt"
77+ "time"
88+99+ "tangled.org/core/appview/models"
1010+ "tangled.org/core/appview/pagination"
1111+)
1212+1313+func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error {
1414+ query := `
1515+ INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id)
1616+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1717+ `
1818+1919+ result, err := d.DB.ExecContext(ctx, query,
2020+ notification.RecipientDid,
2121+ notification.ActorDid,
2222+ string(notification.Type),
2323+ notification.EntityType,
2424+ notification.EntityId,
2525+ notification.Read,
2626+ notification.RepoId,
2727+ notification.IssueId,
2828+ notification.PullId,
2929+ )
3030+ if err != nil {
3131+ return fmt.Errorf("failed to create notification: %w", err)
3232+ }
3333+3434+ id, err := result.LastInsertId()
3535+ if err != nil {
3636+ return fmt.Errorf("failed to get notification ID: %w", err)
3737+ }
3838+3939+ notification.ID = id
4040+ return nil
4141+}
4242+4343+// GetNotificationsPaginated retrieves notifications with filters and pagination
4444+func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) {
4545+ var conditions []string
4646+ var args []any
4747+4848+ for _, filter := range filters {
4949+ conditions = append(conditions, filter.Condition())
5050+ args = append(args, filter.Arg()...)
5151+ }
5252+5353+ whereClause := ""
5454+ if len(conditions) > 0 {
5555+ whereClause = "WHERE " + conditions[0]
5656+ for _, condition := range conditions[1:] {
5757+ whereClause += " AND " + condition
5858+ }
5959+ }
6060+6161+ query := fmt.Sprintf(`
6262+ select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id
6363+ from notifications
6464+ %s
6565+ order by created desc
6666+ limit ? offset ?
6767+ `, whereClause)
6868+6969+ args = append(args, page.Limit, page.Offset)
7070+7171+ rows, err := e.QueryContext(context.Background(), query, args...)
7272+ if err != nil {
7373+ return nil, fmt.Errorf("failed to query notifications: %w", err)
7474+ }
7575+ defer rows.Close()
7676+7777+ var notifications []*models.Notification
7878+ for rows.Next() {
7979+ var n models.Notification
8080+ var typeStr string
8181+ var createdStr string
8282+ err := rows.Scan(
8383+ &n.ID,
8484+ &n.RecipientDid,
8585+ &n.ActorDid,
8686+ &typeStr,
8787+ &n.EntityType,
8888+ &n.EntityId,
8989+ &n.Read,
9090+ &createdStr,
9191+ &n.RepoId,
9292+ &n.IssueId,
9393+ &n.PullId,
9494+ )
9595+ if err != nil {
9696+ return nil, fmt.Errorf("failed to scan notification: %w", err)
9797+ }
9898+ n.Type = models.NotificationType(typeStr)
9999+ n.Created, err = time.Parse(time.RFC3339, createdStr)
100100+ if err != nil {
101101+ return nil, fmt.Errorf("failed to parse created timestamp: %w", err)
102102+ }
103103+ notifications = append(notifications, &n)
104104+ }
105105+106106+ return notifications, nil
107107+}
108108+109109+// GetNotificationsWithEntities retrieves notifications with their related entities
110110+func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) {
111111+ var conditions []string
112112+ var args []any
113113+114114+ for _, filter := range filters {
115115+ conditions = append(conditions, filter.Condition())
116116+ args = append(args, filter.Arg()...)
117117+ }
118118+119119+ whereClause := ""
120120+ if len(conditions) > 0 {
121121+ whereClause = "WHERE " + conditions[0]
122122+ for _, condition := range conditions[1:] {
123123+ whereClause += " AND " + condition
124124+ }
125125+ }
126126+127127+ query := fmt.Sprintf(`
128128+ select
129129+ n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
130130+ n.read, n.created, n.repo_id, n.issue_id, n.pull_id,
131131+ r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description,
132132+ 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,
133133+ 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
134134+ from notifications n
135135+ left join repos r on n.repo_id = r.id
136136+ left join issues i on n.issue_id = i.id
137137+ left join pulls p on n.pull_id = p.id
138138+ %s
139139+ order by n.created desc
140140+ limit ? offset ?
141141+ `, whereClause)
142142+143143+ args = append(args, page.Limit, page.Offset)
144144+145145+ rows, err := e.QueryContext(context.Background(), query, args...)
146146+ if err != nil {
147147+ return nil, fmt.Errorf("failed to query notifications with entities: %w", err)
148148+ }
149149+ defer rows.Close()
150150+151151+ var notifications []*models.NotificationWithEntity
152152+ for rows.Next() {
153153+ var n models.Notification
154154+ var typeStr string
155155+ var createdStr string
156156+ var repo models.Repo
157157+ var issue models.Issue
158158+ var pull models.Pull
159159+ var rId, iId, pId sql.NullInt64
160160+ var rDid, rName, rDescription sql.NullString
161161+ var iDid sql.NullString
162162+ var iIssueId sql.NullInt64
163163+ var iTitle sql.NullString
164164+ var iOpen sql.NullBool
165165+ var pOwnerDid sql.NullString
166166+ var pPullId sql.NullInt64
167167+ var pTitle sql.NullString
168168+ var pState sql.NullInt64
169169+170170+ err := rows.Scan(
171171+ &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
172172+ &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
173173+ &rId, &rDid, &rName, &rDescription,
174174+ &iId, &iDid, &iIssueId, &iTitle, &iOpen,
175175+ &pId, &pOwnerDid, &pPullId, &pTitle, &pState,
176176+ )
177177+ if err != nil {
178178+ return nil, fmt.Errorf("failed to scan notification with entities: %w", err)
179179+ }
180180+181181+ n.Type = models.NotificationType(typeStr)
182182+ n.Created, err = time.Parse(time.RFC3339, createdStr)
183183+ if err != nil {
184184+ return nil, fmt.Errorf("failed to parse created timestamp: %w", err)
185185+ }
186186+187187+ nwe := &models.NotificationWithEntity{Notification: &n}
188188+189189+ // populate repo if present
190190+ if rId.Valid {
191191+ repo.Id = rId.Int64
192192+ if rDid.Valid {
193193+ repo.Did = rDid.String
194194+ }
195195+ if rName.Valid {
196196+ repo.Name = rName.String
197197+ }
198198+ if rDescription.Valid {
199199+ repo.Description = rDescription.String
200200+ }
201201+ nwe.Repo = &repo
202202+ }
203203+204204+ // populate issue if present
205205+ if iId.Valid {
206206+ issue.Id = iId.Int64
207207+ if iDid.Valid {
208208+ issue.Did = iDid.String
209209+ }
210210+ if iIssueId.Valid {
211211+ issue.IssueId = int(iIssueId.Int64)
212212+ }
213213+ if iTitle.Valid {
214214+ issue.Title = iTitle.String
215215+ }
216216+ if iOpen.Valid {
217217+ issue.Open = iOpen.Bool
218218+ }
219219+ nwe.Issue = &issue
220220+ }
221221+222222+ // populate pull if present
223223+ if pId.Valid {
224224+ pull.ID = int(pId.Int64)
225225+ if pOwnerDid.Valid {
226226+ pull.OwnerDid = pOwnerDid.String
227227+ }
228228+ if pPullId.Valid {
229229+ pull.PullId = int(pPullId.Int64)
230230+ }
231231+ if pTitle.Valid {
232232+ pull.Title = pTitle.String
233233+ }
234234+ if pState.Valid {
235235+ pull.State = models.PullState(pState.Int64)
236236+ }
237237+ nwe.Pull = &pull
238238+ }
239239+240240+ notifications = append(notifications, nwe)
241241+ }
242242+243243+ return notifications, nil
244244+}
245245+246246+// GetNotifications retrieves notifications with filters
247247+func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) {
248248+ return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
249249+}
250250+251251+// GetNotifications retrieves notifications for a user with pagination (legacy method for backward compatibility)
252252+func (d *DB) GetNotifications(ctx context.Context, userDID string, limit, offset int) ([]*models.Notification, error) {
253253+ page := pagination.Page{Limit: limit, Offset: offset}
254254+ return GetNotificationsPaginated(d.DB, page, FilterEq("recipient_did", userDID))
255255+}
256256+257257+// GetNotificationsWithEntities retrieves notifications with entities for a user with pagination
258258+func (d *DB) GetNotificationsWithEntities(ctx context.Context, userDID string, limit, offset int) ([]*models.NotificationWithEntity, error) {
259259+ page := pagination.Page{Limit: limit, Offset: offset}
260260+ return GetNotificationsWithEntities(d.DB, page, FilterEq("recipient_did", userDID))
261261+}
262262+263263+func (d *DB) GetUnreadNotificationCount(ctx context.Context, userDID string) (int, error) {
264264+ recipientFilter := FilterEq("recipient_did", userDID)
265265+ readFilter := FilterEq("read", 0)
266266+267267+ query := fmt.Sprintf(`
268268+ SELECT COUNT(*)
269269+ FROM notifications
270270+ WHERE %s AND %s
271271+ `, recipientFilter.Condition(), readFilter.Condition())
272272+273273+ args := append(recipientFilter.Arg(), readFilter.Arg()...)
274274+275275+ var count int
276276+ err := d.DB.QueryRowContext(ctx, query, args...).Scan(&count)
277277+ if err != nil {
278278+ return 0, fmt.Errorf("failed to get unread count: %w", err)
279279+ }
280280+281281+ return count, nil
282282+}
283283+284284+func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error {
285285+ idFilter := FilterEq("id", notificationID)
286286+ recipientFilter := FilterEq("recipient_did", userDID)
287287+288288+ query := fmt.Sprintf(`
289289+ UPDATE notifications
290290+ SET read = 1
291291+ WHERE %s AND %s
292292+ `, idFilter.Condition(), recipientFilter.Condition())
293293+294294+ args := append(idFilter.Arg(), recipientFilter.Arg()...)
295295+296296+ result, err := d.DB.ExecContext(ctx, query, args...)
297297+ if err != nil {
298298+ return fmt.Errorf("failed to mark notification as read: %w", err)
299299+ }
300300+301301+ rowsAffected, err := result.RowsAffected()
302302+ if err != nil {
303303+ return fmt.Errorf("failed to get rows affected: %w", err)
304304+ }
305305+306306+ if rowsAffected == 0 {
307307+ return fmt.Errorf("notification not found or access denied")
308308+ }
309309+310310+ return nil
311311+}
312312+313313+func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error {
314314+ recipientFilter := FilterEq("recipient_did", userDID)
315315+ readFilter := FilterEq("read", 0)
316316+317317+ query := fmt.Sprintf(`
318318+ UPDATE notifications
319319+ SET read = 1
320320+ WHERE %s AND %s
321321+ `, recipientFilter.Condition(), readFilter.Condition())
322322+323323+ args := append(recipientFilter.Arg(), readFilter.Arg()...)
324324+325325+ _, err := d.DB.ExecContext(ctx, query, args...)
326326+ if err != nil {
327327+ return fmt.Errorf("failed to mark all notifications as read: %w", err)
328328+ }
329329+330330+ return nil
331331+}
332332+333333+func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error {
334334+ idFilter := FilterEq("id", notificationID)
335335+ recipientFilter := FilterEq("recipient_did", userDID)
336336+337337+ query := fmt.Sprintf(`
338338+ DELETE FROM notifications
339339+ WHERE %s AND %s
340340+ `, idFilter.Condition(), recipientFilter.Condition())
341341+342342+ args := append(idFilter.Arg(), recipientFilter.Arg()...)
343343+344344+ result, err := d.DB.ExecContext(ctx, query, args...)
345345+ if err != nil {
346346+ return fmt.Errorf("failed to delete notification: %w", err)
347347+ }
348348+349349+ rowsAffected, err := result.RowsAffected()
350350+ if err != nil {
351351+ return fmt.Errorf("failed to get rows affected: %w", err)
352352+ }
353353+354354+ if rowsAffected == 0 {
355355+ return fmt.Errorf("notification not found or access denied")
356356+ }
357357+358358+ return nil
359359+}
360360+361361+func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) {
362362+ userFilter := FilterEq("user_did", userDID)
363363+364364+ query := fmt.Sprintf(`
365365+ SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created,
366366+ pull_commented, followed, pull_merged, issue_closed, email_notifications
367367+ FROM notification_preferences
368368+ WHERE %s
369369+ `, userFilter.Condition())
370370+371371+ var prefs models.NotificationPreferences
372372+ err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan(
373373+ &prefs.ID,
374374+ &prefs.UserDid,
375375+ &prefs.RepoStarred,
376376+ &prefs.IssueCreated,
377377+ &prefs.IssueCommented,
378378+ &prefs.PullCreated,
379379+ &prefs.PullCommented,
380380+ &prefs.Followed,
381381+ &prefs.PullMerged,
382382+ &prefs.IssueClosed,
383383+ &prefs.EmailNotifications,
384384+ )
385385+386386+ if err != nil {
387387+ if err == sql.ErrNoRows {
388388+ return &models.NotificationPreferences{
389389+ UserDid: userDID,
390390+ RepoStarred: true,
391391+ IssueCreated: true,
392392+ IssueCommented: true,
393393+ PullCreated: true,
394394+ PullCommented: true,
395395+ Followed: true,
396396+ PullMerged: true,
397397+ IssueClosed: true,
398398+ EmailNotifications: false,
399399+ }, nil
400400+ }
401401+ return nil, fmt.Errorf("failed to get notification preferences: %w", err)
402402+ }
403403+404404+ return &prefs, nil
405405+}
406406+407407+func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
408408+ query := `
409409+ INSERT OR REPLACE INTO notification_preferences
410410+ (user_did, repo_starred, issue_created, issue_commented, pull_created,
411411+ pull_commented, followed, pull_merged, issue_closed, email_notifications)
412412+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
413413+ `
414414+415415+ result, err := d.DB.ExecContext(ctx, query,
416416+ prefs.UserDid,
417417+ prefs.RepoStarred,
418418+ prefs.IssueCreated,
419419+ prefs.IssueCommented,
420420+ prefs.PullCreated,
421421+ prefs.PullCommented,
422422+ prefs.Followed,
423423+ prefs.PullMerged,
424424+ prefs.IssueClosed,
425425+ prefs.EmailNotifications,
426426+ )
427427+ if err != nil {
428428+ return fmt.Errorf("failed to update notification preferences: %w", err)
429429+ }
430430+431431+ if prefs.ID == 0 {
432432+ id, err := result.LastInsertId()
433433+ if err != nil {
434434+ return fmt.Errorf("failed to get preferences ID: %w", err)
435435+ }
436436+ prefs.ID = id
437437+ }
438438+439439+ return nil
440440+}
441441+442442+func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error {
443443+ cutoff := time.Now().Add(-olderThan)
444444+ createdFilter := FilterLte("created", cutoff)
445445+446446+ query := fmt.Sprintf(`
447447+ DELETE FROM notifications
448448+ WHERE %s
449449+ `, createdFilter.Condition())
450450+451451+ _, err := d.DB.ExecContext(ctx, query, createdFilter.Arg()...)
452452+ if err != nil {
453453+ return fmt.Errorf("failed to cleanup old notifications: %w", err)
454454+ }
455455+456456+ return nil
457457+}