diff --git a/appview/db/db.go b/appview/db/db.go --- a/appview/db/db.go +++ b/appview/db/db.go @@ -2178,6 +2178,22 @@ `) return err }) + orm.RunMigration(conn, logger, "delete-unused-pipeline-statuses", func(tx *sql.Tx) error { + _, err := tx.Exec(` + delete from pipeline_statuses as p + where p.status = 'pending' + and exists ( + select 1 from pipeline_statuses as q + where q.pipeline_knot = p.pipeline_knot + and q.pipeline_rkey = p.pipeline_rkey + and q.workflow = p.workflow + and q.status = 'pending' + and q.created < p.created + ); + `) + return err + }) + orm.RunMigration(conn, logger, "timeline-query-indexes", func(tx *sql.Tx) error { _, err := tx.Exec(` -- following timeline: stars by a set of users, newest first @@ -2192,17 +2208,11 @@ `) return err }) - orm.RunMigration(conn, logger, "delete-unused-pipeline-statuses", func(tx *sql.Tx) error { + orm.RunMigration(conn, logger, "add-focusing-table", func(tx *sql.Tx) error { _, err := tx.Exec(` - delete from pipeline_statuses as p - where p.status = 'pending' - and exists ( - select 1 from pipeline_statuses as q - where q.pipeline_knot = p.pipeline_knot - and q.pipeline_rkey = p.pipeline_rkey - and q.workflow = p.workflow - and q.status = 'pending' - and q.created < p.created + create table if not exists focusing ( + did text primary key, + started text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); `) return err diff --git a/appview/db/focus.go b/appview/db/focus.go new file mode 100644 --- /dev/null +++ b/appview/db/focus.go @@ -0,0 +1,215 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "tangled.org/core/appview/models" +) + +// notification types that qualify for focus mode +var FocusEligibleTypes = []models.NotificationType{ + models.NotificationTypeIssueCreated, + models.NotificationTypeIssueReopen, + models.NotificationTypeIssueCommented, + models.NotificationTypePullCreated, + models.NotificationTypePullReopen, + models.NotificationTypePullCommented, + models.NotificationTypeUserMentioned, +} + +func focusEligiblePlaceholders() (string, []any) { + placeholders := make([]string, len(FocusEligibleTypes)) + args := make([]any, len(FocusEligibleTypes)) + for i, t := range FocusEligibleTypes { + placeholders[i] = "?" + args[i] = string(t) + } + return strings.Join(placeholders, ", "), args +} + +// marks a user as currently focusing +func BeginFocus(e Execer, did string) error { + _, err := e.Exec(`insert or replace into focusing (did) values (?)`, did) + if err != nil { + return fmt.Errorf("BeginFocus: %w", err) + } + return nil +} + +// remove the focusing flag for a user +func EndFocus(e Execer, did string) error { + _, err := e.Exec(`delete from focusing where did = ?`, did) + if err != nil { + return fmt.Errorf("EndFocus: %w", err) + } + return nil +} + +// whether a user is currently in focus mode +func GetFocusStatus(e Execer, did string) (bool, error) { + var exists bool + err := e.QueryRow(`select exists(select 1 from focusing where did = ?)`, did).Scan(&exists) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("GetFocusStatus: %w", err) + } + return exists, nil +} + +// oldest unread focus-eligible notification for the user, with its related entity populated +// +// returns nil, nil when empty +func GetNextFocusItem(e Execer, did string) (*models.NotificationWithEntity, error) { + placeholders, typeArgs := focusEligiblePlaceholders() + + 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.read = 0 + and n.type in (%s) + order by n.created asc + limit 1 + `, placeholders) + + args := append([]any{did}, typeArgs...) + + row := e.QueryRow(query, args...) + + 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 := row.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 errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetNextFocusItem: %w", err) + } + + n.Type = models.NotificationType(typeStr) + n.Created, err = time.Parse(time.RFC3339, createdStr) + if err != nil { + return nil, fmt.Errorf("GetNextFocusItem: parse created: %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 + } + + return entry, nil +} + +// returns the number of unread focus-eligible notifications for a user (not sure if we need this?) +func CountFocusNotifs(e Execer, did string) (int64, error) { + placeholders, typeArgs := focusEligiblePlaceholders() + + query := fmt.Sprintf(` + select count(1) + from notifications + where recipient_did = ? + and read = 0 + and type in (%s) + `, placeholders) + + args := append([]any{did}, typeArgs...) + + var count int64 + err := e.QueryRow(query, args...).Scan(&count) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("CountFocusNotifs: %w", err) + } + return count, nil +}