From a0222ddeca82ecf588ed4af0e938be6df323af06 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Mon, 15 Sep 2025 16:59:11 +0300 Subject: [PATCH] appview/{models,db}: notifications tables, models and helpers Change-Id: rurzxsmsnxkmnskxmoxuuunwwspwmynx Signed-off-by: Anirudh Oppiliappan --- appview/db/db.go | 32 ++- appview/db/notifications.go | 457 ++++++++++++++++++++++++++++++++ appview/models/notifications.go | 54 ++++ appview/models/repo.go | 1 + 4 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 appview/db/notifications.go create mode 100644 appview/models/notifications.go diff --git a/appview/db/db.go b/appview/db/db.go index 6f6d0e41..bb6a52af 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -530,12 +530,42 @@ func Make(dbPath string) (*DB, error) { unique (repo_at, label_at) ); + create table if not exists notifications ( + id integer primary key autoincrement, + recipient_did text not null, + actor_did text not null, + type text not null, + entity_type text not null, + entity_id text not null, + read integer not null default 0, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + repo_id integer references repos(id), + issue_id integer references issues(id), + pull_id integer references pulls(id) + ); + + create table if not exists notification_preferences ( + id integer primary key autoincrement, + user_did text not null unique, + repo_starred integer not null default 1, + issue_created integer not null default 1, + issue_commented integer not null default 1, + pull_created integer not null default 1, + pull_commented integer not null default 1, + followed integer not null default 1, + pull_merged integer not null default 1, + issue_closed integer not null default 1, + email_notifications integer not null default 0 + ); + create table if not exists migrations ( id integer primary key autoincrement, name text unique ); - -- indexes for better star query performance + -- indexes for better performance + create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); + create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); create index if not exists idx_stars_created on stars(created); create index if not exists idx_stars_repo_at_created on stars(repo_at, created); `) diff --git a/appview/db/notifications.go b/appview/db/notifications.go new file mode 100644 index 00000000..f65dcc00 --- /dev/null +++ b/appview/db/notifications.go @@ -0,0 +1,457 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "time" + + "tangled.org/core/appview/models" + "tangled.org/core/appview/pagination" +) + +func (d *DB) CreateNotification(ctx context.Context, 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 := d.DB.ExecContext(ctx, 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 ...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 + } + } + + 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 + 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: %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 +} + +// GetNotificationsWithEntities retrieves notifications with their related entities +func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...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.name as r_name, r.description as r_description, + 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, rName, rDescription 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, &rName, &rDescription, + &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) + } + + nwe := &models.NotificationWithEntity{Notification: &n} + + // populate repo if present + if rId.Valid { + repo.Id = rId.Int64 + if rDid.Valid { + repo.Did = rDid.String + } + if rName.Valid { + repo.Name = rName.String + } + if rDescription.Valid { + repo.Description = rDescription.String + } + nwe.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 + } + nwe.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) + } + nwe.Pull = &pull + } + + notifications = append(notifications, nwe) + } + + return notifications, nil +} + +// GetNotifications retrieves notifications with filters +func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) { + return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) +} + +// GetNotifications retrieves notifications for a user with pagination (legacy method for backward compatibility) +func (d *DB) GetNotifications(ctx context.Context, userDID string, limit, offset int) ([]*models.Notification, error) { + page := pagination.Page{Limit: limit, Offset: offset} + return GetNotificationsPaginated(d.DB, page, FilterEq("recipient_did", userDID)) +} + +// GetNotificationsWithEntities retrieves notifications with entities for a user with pagination +func (d *DB) GetNotificationsWithEntities(ctx context.Context, userDID string, limit, offset int) ([]*models.NotificationWithEntity, error) { + page := pagination.Page{Limit: limit, Offset: offset} + return GetNotificationsWithEntities(d.DB, page, FilterEq("recipient_did", userDID)) +} + +func (d *DB) GetUnreadNotificationCount(ctx context.Context, userDID string) (int, error) { + recipientFilter := FilterEq("recipient_did", userDID) + readFilter := FilterEq("read", 0) + + query := fmt.Sprintf(` + SELECT COUNT(*) + FROM notifications + WHERE %s AND %s + `, recipientFilter.Condition(), readFilter.Condition()) + + args := append(recipientFilter.Arg(), readFilter.Arg()...) + + var count int + err := d.DB.QueryRowContext(ctx, query, args...).Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to get unread count: %w", err) + } + + return count, nil +} + +func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error { + idFilter := FilterEq("id", notificationID) + recipientFilter := 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 := d.DB.ExecContext(ctx, 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 (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error { + recipientFilter := FilterEq("recipient_did", userDID) + readFilter := 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 := d.DB.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to mark all notifications as read: %w", err) + } + + return nil +} + +func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error { + idFilter := FilterEq("id", notificationID) + recipientFilter := 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 := d.DB.ExecContext(ctx, 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 (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) { + userFilter := FilterEq("user_did", userDID) + + query := fmt.Sprintf(` + SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created, + pull_commented, followed, pull_merged, issue_closed, email_notifications + FROM notification_preferences + WHERE %s + `, userFilter.Condition()) + + var prefs models.NotificationPreferences + err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan( + &prefs.ID, + &prefs.UserDid, + &prefs.RepoStarred, + &prefs.IssueCreated, + &prefs.IssueCommented, + &prefs.PullCreated, + &prefs.PullCommented, + &prefs.Followed, + &prefs.PullMerged, + &prefs.IssueClosed, + &prefs.EmailNotifications, + ) + + if err != nil { + if err == sql.ErrNoRows { + return &models.NotificationPreferences{ + UserDid: userDID, + RepoStarred: true, + IssueCreated: true, + IssueCommented: true, + PullCreated: true, + PullCommented: true, + Followed: true, + PullMerged: true, + IssueClosed: true, + EmailNotifications: false, + }, nil + } + return nil, fmt.Errorf("failed to get notification preferences: %w", err) + } + + return &prefs, nil +} + +func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error { + query := ` + INSERT OR REPLACE INTO notification_preferences + (user_did, repo_starred, issue_created, issue_commented, pull_created, + pull_commented, followed, pull_merged, issue_closed, email_notifications) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + + result, err := d.DB.ExecContext(ctx, query, + prefs.UserDid, + prefs.RepoStarred, + prefs.IssueCreated, + prefs.IssueCommented, + prefs.PullCreated, + prefs.PullCommented, + prefs.Followed, + prefs.PullMerged, + prefs.IssueClosed, + prefs.EmailNotifications, + ) + if err != nil { + return fmt.Errorf("failed to update notification preferences: %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 nil +} + +func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error { + cutoff := time.Now().Add(-olderThan) + createdFilter := 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 +} diff --git a/appview/models/notifications.go b/appview/models/notifications.go new file mode 100644 index 00000000..bb467fc2 --- /dev/null +++ b/appview/models/notifications.go @@ -0,0 +1,54 @@ +package models + +import "time" + +type NotificationType string + +const ( + NotificationTypeRepoStarred NotificationType = "repo_starred" + NotificationTypeIssueCreated NotificationType = "issue_created" + NotificationTypeIssueCommented NotificationType = "issue_commented" + NotificationTypePullCreated NotificationType = "pull_created" + NotificationTypePullCommented NotificationType = "pull_commented" + NotificationTypeFollowed NotificationType = "followed" + NotificationTypePullMerged NotificationType = "pull_merged" + NotificationTypeIssueClosed NotificationType = "issue_closed" + NotificationTypePullClosed NotificationType = "pull_closed" +) + +type Notification struct { + ID int64 + RecipientDid string + ActorDid string + Type NotificationType + EntityType string + EntityId string + Read bool + Created time.Time + + // foreign key references + RepoId *int64 + IssueId *int64 + PullId *int64 +} + +type NotificationWithEntity struct { + *Notification + Repo *Repo + Issue *Issue + Pull *Pull +} + +type NotificationPreferences struct { + ID int64 + UserDid string + RepoStarred bool + IssueCreated bool + IssueCommented bool + PullCreated bool + PullCommented bool + Followed bool + PullMerged bool + IssueClosed bool + EmailNotifications bool +} diff --git a/appview/models/repo.go b/appview/models/repo.go index 8bc40544..2a713401 100644 --- a/appview/models/repo.go +++ b/appview/models/repo.go @@ -10,6 +10,7 @@ import ( ) type Repo struct { + Id int64 Did string Name string Knot string -- 2.43.0