This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / db / notifications.go
14 kB 546 lines
1package db 2 3import ( 4 "context" 5 "database/sql" 6 "errors" 7 "fmt" 8 "strings" 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "tangled.org/core/appview/models" 13 "tangled.org/core/appview/pagination" 14 "tangled.org/core/orm" 15) 16 17func CreateNotification(e Execer, notification *models.Notification) error { 18 query := ` 19 INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id) 20 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 21 ` 22 23 result, err := e.Exec(query, 24 notification.RecipientDid, 25 notification.ActorDid, 26 string(notification.Type), 27 notification.EntityType, 28 notification.EntityId, 29 notification.Read, 30 notification.RepoId, 31 notification.IssueId, 32 notification.PullId, 33 ) 34 if err != nil { 35 return fmt.Errorf("failed to create notification: %w", err) 36 } 37 38 id, err := result.LastInsertId() 39 if err != nil { 40 return fmt.Errorf("failed to get notification ID: %w", err) 41 } 42 43 notification.ID = id 44 return nil 45} 46 47// GetNotificationsPaginated retrieves notifications with filters and pagination 48func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.Notification, error) { 49 var conditions []string 50 var args []any 51 52 for _, filter := range filters { 53 conditions = append(conditions, filter.Condition()) 54 args = append(args, filter.Arg()...) 55 } 56 57 whereClause := "" 58 if len(conditions) > 0 { 59 whereClause = "WHERE " + conditions[0] 60 for _, condition := range conditions[1:] { 61 whereClause += " AND " + condition 62 } 63 } 64 pageClause := "" 65 if page.Limit > 0 { 66 pageClause = " limit ? offset ? " 67 args = append(args, page.Limit, page.Offset) 68 } 69 70 query := fmt.Sprintf(` 71 select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id 72 from notifications 73 %s 74 order by created desc 75 %s 76 `, whereClause, pageClause) 77 78 rows, err := e.QueryContext(context.Background(), query, args...) 79 if err != nil { 80 return nil, fmt.Errorf("failed to query notifications: %w", err) 81 } 82 defer rows.Close() 83 84 var notifications []*models.Notification 85 for rows.Next() { 86 var n models.Notification 87 var typeStr string 88 var createdStr string 89 err := rows.Scan( 90 &n.ID, 91 &n.RecipientDid, 92 &n.ActorDid, 93 &typeStr, 94 &n.EntityType, 95 &n.EntityId, 96 &n.Read, 97 &createdStr, 98 &n.RepoId, 99 &n.IssueId, 100 &n.PullId, 101 ) 102 if err != nil { 103 return nil, fmt.Errorf("failed to scan notification: %w", err) 104 } 105 n.Type = models.NotificationType(typeStr) 106 n.Created, err = time.Parse(time.RFC3339, createdStr) 107 if err != nil { 108 return nil, fmt.Errorf("failed to parse created timestamp: %w", err) 109 } 110 notifications = append(notifications, &n) 111 } 112 113 return notifications, nil 114} 115 116func GetNotificationWithEntity(e Execer, notificationID int64, userDID string) (*models.NotificationWithEntity, error) { 117 results, err := GetNotificationsWithEntities(e, pagination.Page{Limit: 1, Offset: 0}, 118 orm.FilterEq("n.id", notificationID), 119 orm.FilterEq("n.recipient_did", userDID), 120 ) 121 if err != nil { 122 return nil, err 123 } 124 if len(results) == 0 { 125 return nil, fmt.Errorf("notification not found") 126 } 127 return results[0], nil 128} 129 130// GetNotificationsWithEntities retrieves notifications with their related entities 131func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.NotificationWithEntity, error) { 132 var conditions []string 133 var args []any 134 135 for _, filter := range filters { 136 conditions = append(conditions, filter.Condition()) 137 args = append(args, filter.Arg()...) 138 } 139 140 whereClause := "" 141 if len(conditions) > 0 { 142 whereClause = "WHERE " + conditions[0] 143 for _, condition := range conditions[1:] { 144 whereClause += " AND " + condition 145 } 146 } 147 148 query := fmt.Sprintf(` 149 select 150 n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, 151 n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 152 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, 153 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, 154 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 155 from notifications n 156 left join repos r on n.repo_id = r.id 157 left join issues i on n.issue_id = i.id 158 left join pulls p on n.pull_id = p.id 159 %s 160 order by n.created desc 161 limit ? offset ? 162 `, whereClause) 163 164 args = append(args, page.Limit, page.Offset) 165 166 rows, err := e.QueryContext(context.Background(), query, args...) 167 if err != nil { 168 return nil, fmt.Errorf("failed to query notifications with entities: %w", err) 169 } 170 defer rows.Close() 171 172 var notifications []*models.NotificationWithEntity 173 for rows.Next() { 174 var n models.Notification 175 var typeStr string 176 var createdStr string 177 var repo models.Repo 178 var issue models.Issue 179 var pull models.Pull 180 var rId, iId, pId sql.NullInt64 181 var rDid, rRkey, rName, rDescription, rWebsite, rTopicStr sql.NullString 182 var iDid sql.NullString 183 var iIssueId sql.NullInt64 184 var iTitle sql.NullString 185 var iOpen sql.NullBool 186 var pOwnerDid sql.NullString 187 var pPullId sql.NullInt64 188 var pTitle sql.NullString 189 var pState sql.NullInt64 190 191 err := rows.Scan( 192 &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, 193 &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, 194 &rId, &rDid, &rRkey, &rName, &rDescription, &rWebsite, &rTopicStr, 195 &iId, &iDid, &iIssueId, &iTitle, &iOpen, 196 &pId, &pOwnerDid, &pPullId, &pTitle, &pState, 197 ) 198 if err != nil { 199 return nil, fmt.Errorf("failed to scan notification with entities: %w", err) 200 } 201 202 n.Type = models.NotificationType(typeStr) 203 n.Created, err = time.Parse(time.RFC3339, createdStr) 204 if err != nil { 205 return nil, fmt.Errorf("failed to parse created timestamp: %w", err) 206 } 207 208 entry := &models.NotificationWithEntity{Notification: &n} 209 210 // populate repo if present 211 if rId.Valid { 212 repo.Id = rId.Int64 213 if rDid.Valid { 214 repo.Did = rDid.String 215 } 216 if rRkey.Valid { 217 repo.Rkey = rRkey.String 218 } 219 if rName.Valid { 220 repo.Name = rName.String 221 } 222 if rDescription.Valid { 223 repo.Description = rDescription.String 224 } 225 if rWebsite.Valid { 226 repo.Website = rWebsite.String 227 } 228 if rTopicStr.Valid { 229 repo.Topics = strings.Fields(rTopicStr.String) 230 } 231 entry.Repo = &repo 232 } 233 234 // populate issue if present 235 if iId.Valid { 236 issue.Id = iId.Int64 237 if iDid.Valid { 238 issue.Did = iDid.String 239 } 240 if iIssueId.Valid { 241 issue.IssueId = int(iIssueId.Int64) 242 } 243 if iTitle.Valid { 244 issue.Title = iTitle.String 245 } 246 if iOpen.Valid { 247 issue.Open = iOpen.Bool 248 } 249 entry.Issue = &issue 250 } 251 252 // populate pull if present 253 if pId.Valid { 254 pull.ID = int(pId.Int64) 255 if pOwnerDid.Valid { 256 pull.OwnerDid = pOwnerDid.String 257 } 258 if pPullId.Valid { 259 pull.PullId = int(pPullId.Int64) 260 } 261 if pTitle.Valid { 262 pull.Title = pTitle.String 263 } 264 if pState.Valid { 265 pull.State = models.PullState(pState.Int64) 266 } 267 entry.Pull = &pull 268 } 269 270 notifications = append(notifications, entry) 271 } 272 273 return notifications, nil 274} 275 276// GetNotifications retrieves notifications with filters 277func GetNotifications(e Execer, filters ...orm.Filter) ([]*models.Notification, error) { 278 return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 279} 280 281func CountNotifications(e Execer, filters ...orm.Filter) (int64, error) { 282 var conditions []string 283 var args []any 284 for _, filter := range filters { 285 conditions = append(conditions, filter.Condition()) 286 args = append(args, filter.Arg()...) 287 } 288 289 whereClause := "" 290 if conditions != nil { 291 whereClause = " where " + strings.Join(conditions, " and ") 292 } 293 294 query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause) 295 var count int64 296 err := e.QueryRow(query, args...).Scan(&count) 297 298 if !errors.Is(err, sql.ErrNoRows) && err != nil { 299 return 0, err 300 } 301 302 return count, nil 303} 304 305func MarkNotificationRead(e Execer, notificationID int64, userDID string) error { 306 idFilter := orm.FilterEq("id", notificationID) 307 recipientFilter := orm.FilterEq("recipient_did", userDID) 308 309 query := fmt.Sprintf(` 310 UPDATE notifications 311 SET read = 1 312 WHERE %s AND %s 313 `, idFilter.Condition(), recipientFilter.Condition()) 314 315 args := append(idFilter.Arg(), recipientFilter.Arg()...) 316 317 result, err := e.Exec(query, args...) 318 if err != nil { 319 return fmt.Errorf("failed to mark notification as read: %w", err) 320 } 321 322 rowsAffected, err := result.RowsAffected() 323 if err != nil { 324 return fmt.Errorf("failed to get rows affected: %w", err) 325 } 326 327 if rowsAffected == 0 { 328 return fmt.Errorf("notification not found or access denied") 329 } 330 331 return nil 332} 333 334func MarkNotificationUnread(e Execer, notificationID int64, userDID string) error { 335 idFilter := orm.FilterEq("id", notificationID) 336 recipientFilter := orm.FilterEq("recipient_did", userDID) 337 338 query := fmt.Sprintf(` 339 UPDATE notifications 340 SET read = 0 341 WHERE %s AND %s 342 `, idFilter.Condition(), recipientFilter.Condition()) 343 344 args := append(idFilter.Arg(), recipientFilter.Arg()...) 345 346 result, err := e.Exec(query, args...) 347 if err != nil { 348 return fmt.Errorf("failed to mark notification as unread: %w", err) 349 } 350 351 rowsAffected, err := result.RowsAffected() 352 if err != nil { 353 return fmt.Errorf("failed to get rows affected: %w", err) 354 } 355 356 if rowsAffected == 0 { 357 return fmt.Errorf("notification not found or access denied") 358 } 359 360 return nil 361} 362 363func MarkAllNotificationsRead(e Execer, userDID string) error { 364 recipientFilter := orm.FilterEq("recipient_did", userDID) 365 readFilter := orm.FilterEq("read", 0) 366 367 query := fmt.Sprintf(` 368 UPDATE notifications 369 SET read = 1 370 WHERE %s AND %s 371 `, recipientFilter.Condition(), readFilter.Condition()) 372 373 args := append(recipientFilter.Arg(), readFilter.Arg()...) 374 375 _, err := e.Exec(query, args...) 376 if err != nil { 377 return fmt.Errorf("failed to mark all notifications as read: %w", err) 378 } 379 380 return nil 381} 382 383func DeleteNotification(e Execer, notificationID int64, userDID string) error { 384 idFilter := orm.FilterEq("id", notificationID) 385 recipientFilter := orm.FilterEq("recipient_did", userDID) 386 387 query := fmt.Sprintf(` 388 DELETE FROM notifications 389 WHERE %s AND %s 390 `, idFilter.Condition(), recipientFilter.Condition()) 391 392 args := append(idFilter.Arg(), recipientFilter.Arg()...) 393 394 result, err := e.Exec(query, args...) 395 if err != nil { 396 return fmt.Errorf("failed to delete notification: %w", err) 397 } 398 399 rowsAffected, err := result.RowsAffected() 400 if err != nil { 401 return fmt.Errorf("failed to get rows affected: %w", err) 402 } 403 404 if rowsAffected == 0 { 405 return fmt.Errorf("notification not found or access denied") 406 } 407 408 return nil 409} 410 411func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) { 412 prefs, err := GetNotificationPreferences(e, orm.FilterEq("user_did", userDid)) 413 if err != nil { 414 return nil, err 415 } 416 417 p, ok := prefs[syntax.DID(userDid)] 418 if !ok { 419 return models.DefaultNotificationPreferences(syntax.DID(userDid)), nil 420 } 421 422 return p, nil 423} 424 425func GetNotificationPreferences(e Execer, filters ...orm.Filter) (map[syntax.DID]*models.NotificationPreferences, error) { 426 prefsMap := make(map[syntax.DID]*models.NotificationPreferences) 427 428 var conditions []string 429 var args []any 430 for _, filter := range filters { 431 conditions = append(conditions, filter.Condition()) 432 args = append(args, filter.Arg()...) 433 } 434 435 whereClause := "" 436 if conditions != nil { 437 whereClause = " where " + strings.Join(conditions, " and ") 438 } 439 440 query := fmt.Sprintf(` 441 select 442 id, 443 user_did, 444 repo_starred, 445 issue_created, 446 issue_commented, 447 pull_created, 448 pull_commented, 449 followed, 450 user_mentioned, 451 pull_merged, 452 issue_closed, 453 email_notifications 454 from 455 notification_preferences 456 %s 457 `, whereClause) 458 459 rows, err := e.Query(query, args...) 460 if err != nil { 461 return nil, err 462 } 463 defer rows.Close() 464 465 for rows.Next() { 466 var prefs models.NotificationPreferences 467 if err := rows.Scan( 468 &prefs.ID, 469 &prefs.UserDid, 470 &prefs.RepoStarred, 471 &prefs.IssueCreated, 472 &prefs.IssueCommented, 473 &prefs.PullCreated, 474 &prefs.PullCommented, 475 &prefs.Followed, 476 &prefs.UserMentioned, 477 &prefs.PullMerged, 478 &prefs.IssueClosed, 479 &prefs.EmailNotifications, 480 ); err != nil { 481 return nil, err 482 } 483 484 prefsMap[prefs.UserDid] = &prefs 485 } 486 487 if err := rows.Err(); err != nil { 488 return nil, err 489 } 490 491 return prefsMap, nil 492} 493 494func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error { 495 query := ` 496 INSERT OR REPLACE INTO notification_preferences 497 (user_did, repo_starred, issue_created, issue_commented, pull_created, 498 pull_commented, followed, user_mentioned, pull_merged, issue_closed, 499 email_notifications) 500 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 501 ` 502 503 result, err := d.DB.ExecContext(ctx, query, 504 prefs.UserDid, 505 prefs.RepoStarred, 506 prefs.IssueCreated, 507 prefs.IssueCommented, 508 prefs.PullCreated, 509 prefs.PullCommented, 510 prefs.Followed, 511 prefs.UserMentioned, 512 prefs.PullMerged, 513 prefs.IssueClosed, 514 prefs.EmailNotifications, 515 ) 516 if err != nil { 517 return fmt.Errorf("failed to update notification preferences: %w", err) 518 } 519 520 if prefs.ID == 0 { 521 id, err := result.LastInsertId() 522 if err != nil { 523 return fmt.Errorf("failed to get preferences ID: %w", err) 524 } 525 prefs.ID = id 526 } 527 528 return nil 529} 530 531func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error { 532 cutoff := time.Now().Add(-olderThan) 533 createdFilter := orm.FilterLte("created", cutoff) 534 535 query := fmt.Sprintf(` 536 DELETE FROM notifications 537 WHERE %s 538 `, createdFilter.Condition()) 539 540 _, err := d.DB.ExecContext(ctx, query, createdFilter.Arg()...) 541 if err != nil { 542 return fmt.Errorf("failed to cleanup old notifications: %w", err) 543 } 544 545 return nil 546}