Signed-off-by: Anirudh Oppiliappan anirudh@tangled.sh
+31
-1
appview/db/db.go
+31
-1
appview/db/db.go
···
530
530
unique (repo_at, label_at)
531
531
);
532
532
533
+
create table if not exists notifications (
534
+
id integer primary key autoincrement,
535
+
recipient_did text not null,
536
+
actor_did text not null,
537
+
type text not null,
538
+
entity_type text not null,
539
+
entity_id text not null,
540
+
read integer not null default 0,
541
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
542
+
repo_id integer references repos(id),
543
+
issue_id integer references issues(id),
544
+
pull_id integer references pulls(id)
545
+
);
546
+
547
+
create table if not exists notification_preferences (
548
+
id integer primary key autoincrement,
549
+
user_did text not null unique,
550
+
repo_starred integer not null default 1,
551
+
issue_created integer not null default 1,
552
+
issue_commented integer not null default 1,
553
+
pull_created integer not null default 1,
554
+
pull_commented integer not null default 1,
555
+
followed integer not null default 1,
556
+
pull_merged integer not null default 1,
557
+
issue_closed integer not null default 1,
558
+
email_notifications integer not null default 0
559
+
);
560
+
533
561
create table if not exists migrations (
534
562
id integer primary key autoincrement,
535
563
name text unique
536
564
);
537
565
538
-
-- indexes for better star query performance
566
+
-- indexes for better performance
567
+
create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
568
+
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
539
569
create index if not exists idx_stars_created on stars(created);
540
570
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
541
571
`)
+457
appview/db/notifications.go
+457
appview/db/notifications.go
···
1
+
package db
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"fmt"
7
+
"time"
8
+
9
+
"tangled.org/core/appview/models"
10
+
"tangled.org/core/appview/pagination"
11
+
)
12
+
13
+
func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error {
14
+
query := `
15
+
INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id)
16
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
17
+
`
18
+
19
+
result, err := d.DB.ExecContext(ctx, query,
20
+
notification.RecipientDid,
21
+
notification.ActorDid,
22
+
string(notification.Type),
23
+
notification.EntityType,
24
+
notification.EntityId,
25
+
notification.Read,
26
+
notification.RepoId,
27
+
notification.IssueId,
28
+
notification.PullId,
29
+
)
30
+
if err != nil {
31
+
return fmt.Errorf("failed to create notification: %w", err)
32
+
}
33
+
34
+
id, err := result.LastInsertId()
35
+
if err != nil {
36
+
return fmt.Errorf("failed to get notification ID: %w", err)
37
+
}
38
+
39
+
notification.ID = id
40
+
return nil
41
+
}
42
+
43
+
// GetNotificationsPaginated retrieves notifications with filters and pagination
44
+
func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) {
45
+
var conditions []string
46
+
var args []any
47
+
48
+
for _, filter := range filters {
49
+
conditions = append(conditions, filter.Condition())
50
+
args = append(args, filter.Arg()...)
51
+
}
52
+
53
+
whereClause := ""
54
+
if len(conditions) > 0 {
55
+
whereClause = "WHERE " + conditions[0]
56
+
for _, condition := range conditions[1:] {
57
+
whereClause += " AND " + condition
58
+
}
59
+
}
60
+
61
+
query := fmt.Sprintf(`
62
+
select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id
63
+
from notifications
64
+
%s
65
+
order by created desc
66
+
limit ? offset ?
67
+
`, whereClause)
68
+
69
+
args = append(args, page.Limit, page.Offset)
70
+
71
+
rows, err := e.QueryContext(context.Background(), query, args...)
72
+
if err != nil {
73
+
return nil, fmt.Errorf("failed to query notifications: %w", err)
74
+
}
75
+
defer rows.Close()
76
+
77
+
var notifications []*models.Notification
78
+
for rows.Next() {
79
+
var n models.Notification
80
+
var typeStr string
81
+
var createdStr string
82
+
err := rows.Scan(
83
+
&n.ID,
84
+
&n.RecipientDid,
85
+
&n.ActorDid,
86
+
&typeStr,
87
+
&n.EntityType,
88
+
&n.EntityId,
89
+
&n.Read,
90
+
&createdStr,
91
+
&n.RepoId,
92
+
&n.IssueId,
93
+
&n.PullId,
94
+
)
95
+
if err != nil {
96
+
return nil, fmt.Errorf("failed to scan notification: %w", err)
97
+
}
98
+
n.Type = models.NotificationType(typeStr)
99
+
n.Created, err = time.Parse(time.RFC3339, createdStr)
100
+
if err != nil {
101
+
return nil, fmt.Errorf("failed to parse created timestamp: %w", err)
102
+
}
103
+
notifications = append(notifications, &n)
104
+
}
105
+
106
+
return notifications, nil
107
+
}
108
+
109
+
// GetNotificationsWithEntities retrieves notifications with their related entities
110
+
func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) {
111
+
var conditions []string
112
+
var args []any
113
+
114
+
for _, filter := range filters {
115
+
conditions = append(conditions, filter.Condition())
116
+
args = append(args, filter.Arg()...)
117
+
}
118
+
119
+
whereClause := ""
120
+
if len(conditions) > 0 {
121
+
whereClause = "WHERE " + conditions[0]
122
+
for _, condition := range conditions[1:] {
123
+
whereClause += " AND " + condition
124
+
}
125
+
}
126
+
127
+
query := fmt.Sprintf(`
128
+
select
129
+
n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
130
+
n.read, n.created, n.repo_id, n.issue_id, n.pull_id,
131
+
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description,
132
+
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,
133
+
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
134
+
from notifications n
135
+
left join repos r on n.repo_id = r.id
136
+
left join issues i on n.issue_id = i.id
137
+
left join pulls p on n.pull_id = p.id
138
+
%s
139
+
order by n.created desc
140
+
limit ? offset ?
141
+
`, whereClause)
142
+
143
+
args = append(args, page.Limit, page.Offset)
144
+
145
+
rows, err := e.QueryContext(context.Background(), query, args...)
146
+
if err != nil {
147
+
return nil, fmt.Errorf("failed to query notifications with entities: %w", err)
148
+
}
149
+
defer rows.Close()
150
+
151
+
var notifications []*models.NotificationWithEntity
152
+
for rows.Next() {
153
+
var n models.Notification
154
+
var typeStr string
155
+
var createdStr string
156
+
var repo models.Repo
157
+
var issue models.Issue
158
+
var pull models.Pull
159
+
var rId, iId, pId sql.NullInt64
160
+
var rDid, rName, rDescription sql.NullString
161
+
var iDid sql.NullString
162
+
var iIssueId sql.NullInt64
163
+
var iTitle sql.NullString
164
+
var iOpen sql.NullBool
165
+
var pOwnerDid sql.NullString
166
+
var pPullId sql.NullInt64
167
+
var pTitle sql.NullString
168
+
var pState sql.NullInt64
169
+
170
+
err := rows.Scan(
171
+
&n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
172
+
&n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
173
+
&rId, &rDid, &rName, &rDescription,
174
+
&iId, &iDid, &iIssueId, &iTitle, &iOpen,
175
+
&pId, &pOwnerDid, &pPullId, &pTitle, &pState,
176
+
)
177
+
if err != nil {
178
+
return nil, fmt.Errorf("failed to scan notification with entities: %w", err)
179
+
}
180
+
181
+
n.Type = models.NotificationType(typeStr)
182
+
n.Created, err = time.Parse(time.RFC3339, createdStr)
183
+
if err != nil {
184
+
return nil, fmt.Errorf("failed to parse created timestamp: %w", err)
185
+
}
186
+
187
+
nwe := &models.NotificationWithEntity{Notification: &n}
188
+
189
+
// populate repo if present
190
+
if rId.Valid {
191
+
repo.Id = rId.Int64
192
+
if rDid.Valid {
193
+
repo.Did = rDid.String
194
+
}
195
+
if rName.Valid {
196
+
repo.Name = rName.String
197
+
}
198
+
if rDescription.Valid {
199
+
repo.Description = rDescription.String
200
+
}
201
+
nwe.Repo = &repo
202
+
}
203
+
204
+
// populate issue if present
205
+
if iId.Valid {
206
+
issue.Id = iId.Int64
207
+
if iDid.Valid {
208
+
issue.Did = iDid.String
209
+
}
210
+
if iIssueId.Valid {
211
+
issue.IssueId = int(iIssueId.Int64)
212
+
}
213
+
if iTitle.Valid {
214
+
issue.Title = iTitle.String
215
+
}
216
+
if iOpen.Valid {
217
+
issue.Open = iOpen.Bool
218
+
}
219
+
nwe.Issue = &issue
220
+
}
221
+
222
+
// populate pull if present
223
+
if pId.Valid {
224
+
pull.ID = int(pId.Int64)
225
+
if pOwnerDid.Valid {
226
+
pull.OwnerDid = pOwnerDid.String
227
+
}
228
+
if pPullId.Valid {
229
+
pull.PullId = int(pPullId.Int64)
230
+
}
231
+
if pTitle.Valid {
232
+
pull.Title = pTitle.String
233
+
}
234
+
if pState.Valid {
235
+
pull.State = models.PullState(pState.Int64)
236
+
}
237
+
nwe.Pull = &pull
238
+
}
239
+
240
+
notifications = append(notifications, nwe)
241
+
}
242
+
243
+
return notifications, nil
244
+
}
245
+
246
+
// GetNotifications retrieves notifications with filters
247
+
func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) {
248
+
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
249
+
}
250
+
251
+
// GetNotifications retrieves notifications for a user with pagination (legacy method for backward compatibility)
252
+
func (d *DB) GetNotifications(ctx context.Context, userDID string, limit, offset int) ([]*models.Notification, error) {
253
+
page := pagination.Page{Limit: limit, Offset: offset}
254
+
return GetNotificationsPaginated(d.DB, page, FilterEq("recipient_did", userDID))
255
+
}
256
+
257
+
// GetNotificationsWithEntities retrieves notifications with entities for a user with pagination
258
+
func (d *DB) GetNotificationsWithEntities(ctx context.Context, userDID string, limit, offset int) ([]*models.NotificationWithEntity, error) {
259
+
page := pagination.Page{Limit: limit, Offset: offset}
260
+
return GetNotificationsWithEntities(d.DB, page, FilterEq("recipient_did", userDID))
261
+
}
262
+
263
+
func (d *DB) GetUnreadNotificationCount(ctx context.Context, userDID string) (int, error) {
264
+
recipientFilter := FilterEq("recipient_did", userDID)
265
+
readFilter := FilterEq("read", 0)
266
+
267
+
query := fmt.Sprintf(`
268
+
SELECT COUNT(*)
269
+
FROM notifications
270
+
WHERE %s AND %s
271
+
`, recipientFilter.Condition(), readFilter.Condition())
272
+
273
+
args := append(recipientFilter.Arg(), readFilter.Arg()...)
274
+
275
+
var count int
276
+
err := d.DB.QueryRowContext(ctx, query, args...).Scan(&count)
277
+
if err != nil {
278
+
return 0, fmt.Errorf("failed to get unread count: %w", err)
279
+
}
280
+
281
+
return count, nil
282
+
}
283
+
284
+
func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error {
285
+
idFilter := FilterEq("id", notificationID)
286
+
recipientFilter := FilterEq("recipient_did", userDID)
287
+
288
+
query := fmt.Sprintf(`
289
+
UPDATE notifications
290
+
SET read = 1
291
+
WHERE %s AND %s
292
+
`, idFilter.Condition(), recipientFilter.Condition())
293
+
294
+
args := append(idFilter.Arg(), recipientFilter.Arg()...)
295
+
296
+
result, err := d.DB.ExecContext(ctx, query, args...)
297
+
if err != nil {
298
+
return fmt.Errorf("failed to mark notification as read: %w", err)
299
+
}
300
+
301
+
rowsAffected, err := result.RowsAffected()
302
+
if err != nil {
303
+
return fmt.Errorf("failed to get rows affected: %w", err)
304
+
}
305
+
306
+
if rowsAffected == 0 {
307
+
return fmt.Errorf("notification not found or access denied")
308
+
}
309
+
310
+
return nil
311
+
}
312
+
313
+
func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error {
314
+
recipientFilter := FilterEq("recipient_did", userDID)
315
+
readFilter := FilterEq("read", 0)
316
+
317
+
query := fmt.Sprintf(`
318
+
UPDATE notifications
319
+
SET read = 1
320
+
WHERE %s AND %s
321
+
`, recipientFilter.Condition(), readFilter.Condition())
322
+
323
+
args := append(recipientFilter.Arg(), readFilter.Arg()...)
324
+
325
+
_, err := d.DB.ExecContext(ctx, query, args...)
326
+
if err != nil {
327
+
return fmt.Errorf("failed to mark all notifications as read: %w", err)
328
+
}
329
+
330
+
return nil
331
+
}
332
+
333
+
func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error {
334
+
idFilter := FilterEq("id", notificationID)
335
+
recipientFilter := FilterEq("recipient_did", userDID)
336
+
337
+
query := fmt.Sprintf(`
338
+
DELETE FROM notifications
339
+
WHERE %s AND %s
340
+
`, idFilter.Condition(), recipientFilter.Condition())
341
+
342
+
args := append(idFilter.Arg(), recipientFilter.Arg()...)
343
+
344
+
result, err := d.DB.ExecContext(ctx, query, args...)
345
+
if err != nil {
346
+
return fmt.Errorf("failed to delete notification: %w", err)
347
+
}
348
+
349
+
rowsAffected, err := result.RowsAffected()
350
+
if err != nil {
351
+
return fmt.Errorf("failed to get rows affected: %w", err)
352
+
}
353
+
354
+
if rowsAffected == 0 {
355
+
return fmt.Errorf("notification not found or access denied")
356
+
}
357
+
358
+
return nil
359
+
}
360
+
361
+
func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) {
362
+
userFilter := FilterEq("user_did", userDID)
363
+
364
+
query := fmt.Sprintf(`
365
+
SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created,
366
+
pull_commented, followed, pull_merged, issue_closed, email_notifications
367
+
FROM notification_preferences
368
+
WHERE %s
369
+
`, userFilter.Condition())
370
+
371
+
var prefs models.NotificationPreferences
372
+
err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan(
373
+
&prefs.ID,
374
+
&prefs.UserDid,
375
+
&prefs.RepoStarred,
376
+
&prefs.IssueCreated,
377
+
&prefs.IssueCommented,
378
+
&prefs.PullCreated,
379
+
&prefs.PullCommented,
380
+
&prefs.Followed,
381
+
&prefs.PullMerged,
382
+
&prefs.IssueClosed,
383
+
&prefs.EmailNotifications,
384
+
)
385
+
386
+
if err != nil {
387
+
if err == sql.ErrNoRows {
388
+
return &models.NotificationPreferences{
389
+
UserDid: userDID,
390
+
RepoStarred: true,
391
+
IssueCreated: true,
392
+
IssueCommented: true,
393
+
PullCreated: true,
394
+
PullCommented: true,
395
+
Followed: true,
396
+
PullMerged: true,
397
+
IssueClosed: true,
398
+
EmailNotifications: false,
399
+
}, nil
400
+
}
401
+
return nil, fmt.Errorf("failed to get notification preferences: %w", err)
402
+
}
403
+
404
+
return &prefs, nil
405
+
}
406
+
407
+
func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
408
+
query := `
409
+
INSERT OR REPLACE INTO notification_preferences
410
+
(user_did, repo_starred, issue_created, issue_commented, pull_created,
411
+
pull_commented, followed, pull_merged, issue_closed, email_notifications)
412
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
413
+
`
414
+
415
+
result, err := d.DB.ExecContext(ctx, query,
416
+
prefs.UserDid,
417
+
prefs.RepoStarred,
418
+
prefs.IssueCreated,
419
+
prefs.IssueCommented,
420
+
prefs.PullCreated,
421
+
prefs.PullCommented,
422
+
prefs.Followed,
423
+
prefs.PullMerged,
424
+
prefs.IssueClosed,
425
+
prefs.EmailNotifications,
426
+
)
427
+
if err != nil {
428
+
return fmt.Errorf("failed to update notification preferences: %w", err)
429
+
}
430
+
431
+
if prefs.ID == 0 {
432
+
id, err := result.LastInsertId()
433
+
if err != nil {
434
+
return fmt.Errorf("failed to get preferences ID: %w", err)
435
+
}
436
+
prefs.ID = id
437
+
}
438
+
439
+
return nil
440
+
}
441
+
442
+
func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error {
443
+
cutoff := time.Now().Add(-olderThan)
444
+
createdFilter := FilterLte("created", cutoff)
445
+
446
+
query := fmt.Sprintf(`
447
+
DELETE FROM notifications
448
+
WHERE %s
449
+
`, createdFilter.Condition())
450
+
451
+
_, err := d.DB.ExecContext(ctx, query, createdFilter.Arg()...)
452
+
if err != nil {
453
+
return fmt.Errorf("failed to cleanup old notifications: %w", err)
454
+
}
455
+
456
+
return nil
457
+
}
+54
appview/models/notifications.go
+54
appview/models/notifications.go
···
1
+
package models
2
+
3
+
import "time"
4
+
5
+
type NotificationType string
6
+
7
+
const (
8
+
NotificationTypeRepoStarred NotificationType = "repo_starred"
9
+
NotificationTypeIssueCreated NotificationType = "issue_created"
10
+
NotificationTypeIssueCommented NotificationType = "issue_commented"
11
+
NotificationTypePullCreated NotificationType = "pull_created"
12
+
NotificationTypePullCommented NotificationType = "pull_commented"
13
+
NotificationTypeFollowed NotificationType = "followed"
14
+
NotificationTypePullMerged NotificationType = "pull_merged"
15
+
NotificationTypeIssueClosed NotificationType = "issue_closed"
16
+
NotificationTypePullClosed NotificationType = "pull_closed"
17
+
)
18
+
19
+
type Notification struct {
20
+
ID int64
21
+
RecipientDid string
22
+
ActorDid string
23
+
Type NotificationType
24
+
EntityType string
25
+
EntityId string
26
+
Read bool
27
+
Created time.Time
28
+
29
+
// foreign key references
30
+
RepoId *int64
31
+
IssueId *int64
32
+
PullId *int64
33
+
}
34
+
35
+
type NotificationWithEntity struct {
36
+
*Notification
37
+
Repo *Repo
38
+
Issue *Issue
39
+
Pull *Pull
40
+
}
41
+
42
+
type NotificationPreferences struct {
43
+
ID int64
44
+
UserDid string
45
+
RepoStarred bool
46
+
IssueCreated bool
47
+
IssueCommented bool
48
+
PullCreated bool
49
+
PullCommented bool
50
+
Followed bool
51
+
PullMerged bool
52
+
IssueClosed bool
53
+
EmailNotifications bool
54
+
}