package db import ( "context" "database/sql" "errors" "fmt" "strings" "time" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/appview/models" "tangled.org/core/appview/pagination" "tangled.org/core/orm" ) func CreateNotification(e Execer, notification *models.Notification) error { query := ` INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ` result, err := e.Exec(query, notification.RecipientDid, notification.ActorDid, string(notification.Type), notification.EntityType, notification.EntityId, notification.Read, notification.RepoId, notification.IssueId, notification.PullId, ) if err != nil { return fmt.Errorf("failed to create notification: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get notification ID: %w", err) } notification.ID = id return nil } // GetNotificationsPaginated retrieves notifications with filters and pagination func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.Notification, error) { var conditions []string var args []any for _, filter := range filters { conditions = append(conditions, filter.Condition()) args = append(args, filter.Arg()...) } whereClause := "" if len(conditions) > 0 { whereClause = "WHERE " + conditions[0] for _, condition := range conditions[1:] { whereClause += " AND " + condition } } pageClause := "" if page.Limit > 0 { pageClause = " limit ? offset ? " args = append(args, page.Limit, page.Offset) } query := fmt.Sprintf(` select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id from notifications %s order by created desc %s `, whereClause, pageClause) rows, err := e.QueryContext(context.Background(), query, args...) if err != nil { return nil, fmt.Errorf("failed to query notifications: %w", err) } defer rows.Close() var notifications []*models.Notification for rows.Next() { var n models.Notification var typeStr string var createdStr string err := rows.Scan( &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, ) if err != nil { return nil, fmt.Errorf("failed to scan notification: %w", err) } n.Type = models.NotificationType(typeStr) n.Created, err = time.Parse(time.RFC3339, createdStr) if err != nil { return nil, fmt.Errorf("failed to parse created timestamp: %w", err) } notifications = append(notifications, &n) } return notifications, nil } func GetNotificationWithEntity(e Execer, notificationID int64, userDID string) (*models.NotificationWithEntity, error) { results, err := GetNotificationsWithEntities(e, pagination.Page{Limit: 1, Offset: 0}, orm.FilterEq("n.id", notificationID), orm.FilterEq("n.recipient_did", userDID), ) if err != nil { return nil, err } if len(results) == 0 { return nil, fmt.Errorf("notification not found") } return results[0], nil } // GetNotificationsWithEntities retrieves notifications with their related entities func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.NotificationWithEntity, error) { var conditions []string var args []any for _, filter := range filters { conditions = append(conditions, filter.Condition()) args = append(args, filter.Arg()...) } whereClause := "" if len(conditions) > 0 { whereClause = "WHERE " + conditions[0] for _, condition := range conditions[1:] { whereClause += " AND " + condition } } query := fmt.Sprintf(` select n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 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, 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, 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 from notifications n left join repos r on n.repo_id = r.id left join issues i on n.issue_id = i.id left join pulls p on n.pull_id = p.id %s order by n.created desc limit ? offset ? `, whereClause) args = append(args, page.Limit, page.Offset) rows, err := e.QueryContext(context.Background(), query, args...) if err != nil { return nil, fmt.Errorf("failed to query notifications with entities: %w", err) } defer rows.Close() var notifications []*models.NotificationWithEntity for rows.Next() { var n models.Notification var typeStr string var createdStr string var repo models.Repo var issue models.Issue var pull models.Pull var rId, iId, pId sql.NullInt64 var rDid, rRkey, rName, rDescription, rWebsite, rTopicStr sql.NullString var iDid sql.NullString var iIssueId sql.NullInt64 var iTitle sql.NullString var iOpen sql.NullBool var pOwnerDid sql.NullString var pPullId sql.NullInt64 var pTitle sql.NullString var pState sql.NullInt64 err := rows.Scan( &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, &rId, &rDid, &rRkey, &rName, &rDescription, &rWebsite, &rTopicStr, &iId, &iDid, &iIssueId, &iTitle, &iOpen, &pId, &pOwnerDid, &pPullId, &pTitle, &pState, ) if err != nil { return nil, fmt.Errorf("failed to scan notification with entities: %w", err) } n.Type = models.NotificationType(typeStr) n.Created, err = time.Parse(time.RFC3339, createdStr) if err != nil { return nil, fmt.Errorf("failed to parse created timestamp: %w", err) } entry := &models.NotificationWithEntity{Notification: &n} // populate repo if present if rId.Valid { repo.Id = rId.Int64 if rDid.Valid { repo.Did = rDid.String } if rRkey.Valid { repo.Rkey = rRkey.String } if rName.Valid { repo.Name = rName.String } if rDescription.Valid { repo.Description = rDescription.String } if rWebsite.Valid { repo.Website = rWebsite.String } if rTopicStr.Valid { repo.Topics = strings.Fields(rTopicStr.String) } entry.Repo = &repo } // populate issue if present if iId.Valid { issue.Id = iId.Int64 if iDid.Valid { issue.Did = iDid.String } if iIssueId.Valid { issue.IssueId = int(iIssueId.Int64) } if iTitle.Valid { issue.Title = iTitle.String } if iOpen.Valid { issue.Open = iOpen.Bool } entry.Issue = &issue } // populate pull if present if pId.Valid { pull.ID = int(pId.Int64) if pOwnerDid.Valid { pull.OwnerDid = pOwnerDid.String } if pPullId.Valid { pull.PullId = int(pPullId.Int64) } if pTitle.Valid { pull.Title = pTitle.String } if pState.Valid { pull.State = models.PullState(pState.Int64) } entry.Pull = &pull } notifications = append(notifications, entry) } return notifications, nil } // GetNotifications retrieves notifications with filters func GetNotifications(e Execer, filters ...orm.Filter) ([]*models.Notification, error) { return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) } func CountNotifications(e Execer, filters ...orm.Filter) (int64, error) { var conditions []string var args []any for _, filter := range filters { conditions = append(conditions, filter.Condition()) args = append(args, filter.Arg()...) } whereClause := "" if conditions != nil { whereClause = " where " + strings.Join(conditions, " and ") } query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause) var count int64 err := e.QueryRow(query, args...).Scan(&count) if !errors.Is(err, sql.ErrNoRows) && err != nil { return 0, err } return count, nil } func MarkNotificationRead(e Execer, notificationID int64, userDID string) error { idFilter := orm.FilterEq("id", notificationID) recipientFilter := orm.FilterEq("recipient_did", userDID) query := fmt.Sprintf(` UPDATE notifications SET read = 1 WHERE %s AND %s `, idFilter.Condition(), recipientFilter.Condition()) args := append(idFilter.Arg(), recipientFilter.Arg()...) result, err := e.Exec(query, args...) if err != nil { return fmt.Errorf("failed to mark notification as read: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rowsAffected == 0 { return fmt.Errorf("notification not found or access denied") } return nil } func MarkNotificationsReadForIssue(e Execer, userDID, repoDid string, issueNum int) error { query := ` update notifications set read = 1 where recipient_did = ? and read = 0 and issue_id = (select id from issues where repo_did = ? and issue_id = ?) ` _, err := e.Exec(query, userDID, repoDid, issueNum) return err } func MarkNotificationsReadForPull(e Execer, userDID, repoDid string, pullNum int) error { query := ` update notifications set read = 1 where recipient_did = ? and read = 0 and pull_id = (select p.id from pulls p where p.pull_id = ? and p.repo_did = ?) ` _, err := e.Exec(query, userDID, pullNum, repoDid) return err } func MarkNotificationUnread(e Execer, notificationID int64, userDID string) error { idFilter := orm.FilterEq("id", notificationID) recipientFilter := orm.FilterEq("recipient_did", userDID) query := fmt.Sprintf(` UPDATE notifications SET read = 0 WHERE %s AND %s `, idFilter.Condition(), recipientFilter.Condition()) args := append(idFilter.Arg(), recipientFilter.Arg()...) result, err := e.Exec(query, args...) if err != nil { return fmt.Errorf("failed to mark notification as unread: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rowsAffected == 0 { return fmt.Errorf("notification not found or access denied") } return nil } func MarkAllNotificationsRead(e Execer, userDID string) error { recipientFilter := orm.FilterEq("recipient_did", userDID) readFilter := orm.FilterEq("read", 0) query := fmt.Sprintf(` UPDATE notifications SET read = 1 WHERE %s AND %s `, recipientFilter.Condition(), readFilter.Condition()) args := append(recipientFilter.Arg(), readFilter.Arg()...) _, err := e.Exec(query, args...) if err != nil { return fmt.Errorf("failed to mark all notifications as read: %w", err) } return nil } func DeleteNotification(e Execer, notificationID int64, userDID string) error { idFilter := orm.FilterEq("id", notificationID) recipientFilter := orm.FilterEq("recipient_did", userDID) query := fmt.Sprintf(` DELETE FROM notifications WHERE %s AND %s `, idFilter.Condition(), recipientFilter.Condition()) args := append(idFilter.Arg(), recipientFilter.Arg()...) result, err := e.Exec(query, args...) if err != nil { return fmt.Errorf("failed to delete notification: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rowsAffected == 0 { return fmt.Errorf("notification not found or access denied") } return nil } func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) { prefs, err := GetNotificationPreferences(e, orm.FilterEq("user_did", userDid)) if err != nil { return nil, err } p, ok := prefs[syntax.DID(userDid)] if !ok { return models.DefaultNotificationPreferences(syntax.DID(userDid)), nil } return p, nil } func GetNotificationPreferences(e Execer, filters ...orm.Filter) (map[syntax.DID]*models.NotificationPreferences, error) { prefsMap := make(map[syntax.DID]*models.NotificationPreferences) var conditions []string var args []any for _, filter := range filters { conditions = append(conditions, filter.Condition()) args = append(args, filter.Arg()...) } whereClause := "" if conditions != nil { whereClause = " where " + strings.Join(conditions, " and ") } query := fmt.Sprintf(` select id, user_did, repo_starred, issue_created, issue_commented, pull_created, pull_commented, followed, user_mentioned, pull_merged, issue_closed, email_notifications from notification_preferences %s `, whereClause) rows, err := e.Query(query, args...) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var prefs models.NotificationPreferences if err := rows.Scan( &prefs.ID, &prefs.UserDid, &prefs.RepoStarred, &prefs.IssueCreated, &prefs.IssueCommented, &prefs.PullCreated, &prefs.PullCommented, &prefs.Followed, &prefs.UserMentioned, &prefs.PullMerged, &prefs.IssueClosed, &prefs.EmailNotifications, ); err != nil { return nil, err } prefsMap[prefs.UserDid] = &prefs } if err := rows.Err(); err != nil { return nil, err } return prefsMap, nil } func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error { tx, err := d.DB.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() var prevEmailEnabled bool var hadPrefs bool row := tx.QueryRowContext(ctx, `SELECT email_notifications FROM notification_preferences WHERE user_did = ?`, prefs.UserDid, ) switch err := row.Scan(&prevEmailEnabled); err { case nil: hadPrefs = true case sql.ErrNoRows: hadPrefs = false default: return fmt.Errorf("failed to read existing preferences: %w", err) } query := ` INSERT OR REPLACE INTO notification_preferences (user_did, repo_starred, issue_created, issue_commented, pull_created, pull_commented, followed, user_mentioned, pull_merged, issue_closed, email_notifications) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` result, err := tx.ExecContext(ctx, query, prefs.UserDid, prefs.RepoStarred, prefs.IssueCreated, prefs.IssueCommented, prefs.PullCreated, prefs.PullCommented, prefs.Followed, prefs.UserMentioned, prefs.PullMerged, prefs.IssueClosed, prefs.EmailNotifications, ) if err != nil { return fmt.Errorf("failed to update notification preferences: %w", err) } // on enabling email notifications (from disabled or from no prior setting), // mark existing unemailed notifications as emailed so they aren't dispatched // as a backlog on the next digest tick. if prefs.EmailNotifications && (!hadPrefs || !prevEmailEnabled) { if err := MarkAllNotificationsEmailed(tx, string(prefs.UserDid)); err != nil { return fmt.Errorf("failed to suppress notification backlog: %w", err) } } if prefs.ID == 0 { id, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get preferences ID: %w", err) } prefs.ID = id } return tx.Commit() } // GetPendingEmailDigestRecipients returns DIDs of users who have email // notifications enabled, have a verified primary email, and have unread, // unemailed notifications older than olderThan for email-eligible notification // types. func GetPendingEmailDigestRecipients(e Execer, olderThan time.Time) ([]string, error) { placeholders := make([]string, len(models.EmailNotificationTypes)) args := []any{olderThan.UTC().Format(time.RFC3339)} for i, t := range models.EmailNotificationTypes { placeholders[i] = "?" args = append(args, string(t)) } inClause := strings.Join(placeholders, ", ") query := fmt.Sprintf(` SELECT DISTINCT n.recipient_did FROM notifications n JOIN notification_preferences np ON np.user_did = n.recipient_did JOIN emails e ON e.did = n.recipient_did AND e.is_primary = 1 AND e.verified = 1 WHERE n.emailed = 0 AND n.read = 0 AND n.created < ? AND np.email_notifications = 1 AND n.type IN (%s) `, inClause) rows, err := e.Query(query, args...) if err != nil { return nil, fmt.Errorf("failed to query email digest recipients: %w", err) } defer rows.Close() var dids []string for rows.Next() { var did string if err := rows.Scan(&did); err != nil { return nil, err } dids = append(dids, did) } return dids, rows.Err() } // GetPendingNotificationsForEmailDigest returns all unread, unemailed // notifications older than olderThan for email-eligible types for a specific // recipient. func GetPendingNotificationsForEmailDigest(e Execer, recipientDid string, olderThan time.Time) ([]*models.NotificationWithEntity, error) { placeholders := make([]string, len(models.EmailNotificationTypes)) args := []any{recipientDid, olderThan.UTC().Format(time.RFC3339)} for i, t := range models.EmailNotificationTypes { placeholders[i] = "?" args = append(args, string(t)) } inClause := strings.Join(placeholders, ", ") query := fmt.Sprintf(` SELECT n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 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, 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, 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 FROM notifications n LEFT JOIN repos r ON n.repo_id = r.id LEFT JOIN issues i ON n.issue_id = i.id LEFT JOIN pulls p ON n.pull_id = p.id WHERE n.recipient_did = ? AND n.emailed = 0 AND n.read = 0 AND n.created < ? AND n.type IN (%s) ORDER BY n.created DESC `, inClause) rows, err := e.QueryContext(context.Background(), query, args...) if err != nil { return nil, fmt.Errorf("failed to query pending email notifications: %w", err) } defer rows.Close() var notifications []*models.NotificationWithEntity for rows.Next() { var n models.Notification var typeStr string var createdStr string var repo models.Repo var issue models.Issue var pull models.Pull var rId, iId, pId sql.NullInt64 var rDid, rRkey, rName, rDescription, rWebsite, rTopicStr sql.NullString var iDid sql.NullString var iIssueId sql.NullInt64 var iTitle sql.NullString var iOpen sql.NullBool var pOwnerDid sql.NullString var pPullId sql.NullInt64 var pTitle sql.NullString var pState sql.NullInt64 err := rows.Scan( &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, &rId, &rDid, &rRkey, &rName, &rDescription, &rWebsite, &rTopicStr, &iId, &iDid, &iIssueId, &iTitle, &iOpen, &pId, &pOwnerDid, &pPullId, &pTitle, &pState, ) if err != nil { return nil, fmt.Errorf("failed to scan email digest notification: %w", err) } n.Type = models.NotificationType(typeStr) n.Created, err = time.Parse(time.RFC3339, createdStr) if err != nil { return nil, fmt.Errorf("failed to parse created timestamp: %w", err) } entry := &models.NotificationWithEntity{Notification: &n} if rId.Valid { repo.Id = rId.Int64 if rDid.Valid { repo.Did = rDid.String } if rRkey.Valid { repo.Rkey = rRkey.String } if rName.Valid { repo.Name = rName.String } if rDescription.Valid { repo.Description = rDescription.String } if rWebsite.Valid { repo.Website = rWebsite.String } if rTopicStr.Valid { repo.Topics = strings.Fields(rTopicStr.String) } entry.Repo = &repo } if iId.Valid { issue.Id = iId.Int64 if iDid.Valid { issue.Did = iDid.String } if iIssueId.Valid { issue.IssueId = int(iIssueId.Int64) } if iTitle.Valid { issue.Title = iTitle.String } if iOpen.Valid { issue.Open = iOpen.Bool } entry.Issue = &issue } if pId.Valid { pull.ID = int(pId.Int64) if pOwnerDid.Valid { pull.OwnerDid = pOwnerDid.String } if pPullId.Valid { pull.PullId = int(pPullId.Int64) } if pTitle.Valid { pull.Title = pTitle.String } if pState.Valid { pull.State = models.PullState(pState.Int64) } entry.Pull = &pull } notifications = append(notifications, entry) } return notifications, rows.Err() } // MarkNotificationsEmailed marks the given notification IDs as emailed=1. // Uses explicit IDs (not recipient_did) to avoid racing with new notifications. func MarkNotificationsEmailed(e Execer, ids []int64) error { if len(ids) == 0 { return nil } placeholders := make([]string, len(ids)) args := make([]any, len(ids)) for i, id := range ids { placeholders[i] = "?" args[i] = id } query := fmt.Sprintf( `UPDATE notifications SET emailed = 1 WHERE id IN (%s)`, strings.Join(placeholders, ", "), ) _, err := e.Exec(query, args...) return err } // MarkAllNotificationsEmailed marks every currently-unemailed notification for // the recipient as emailed=1. Used when a user re-enables email notifications so // the pre-existing backlog isn't dispatched in one digest. func MarkAllNotificationsEmailed(e Execer, recipientDid string) error { _, err := e.Exec( `UPDATE notifications SET emailed = 1 WHERE recipient_did = ? AND emailed = 0`, recipientDid, ) return err } func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error { cutoff := time.Now().Add(-olderThan) createdFilter := orm.FilterLte("created", cutoff) query := fmt.Sprintf(` DELETE FROM notifications WHERE %s `, createdFilter.Condition()) _, err := d.DB.ExecContext(ctx, query, createdFilter.Arg()...) if err != nil { return fmt.Errorf("failed to cleanup old notifications: %w", err) } return nil }