diff --git a/flake.lock b/flake.lock --- a/flake.lock +++ b/flake.lock @@ -117,14 +117,14 @@ "ibm-plex-mono-src": { "flake": false, "locked": { - "lastModified": 1731402378, + "lastModified": 1731402384, "narHash": "sha256-OwUmrPfEehLDz0fl2ChYLK8FQM2p0G1+EMrGsYEq+6g=", "type": "tarball", - "url": "https://github.com/IBM/plex/releases/download/@ibm/plex-mono@1.1.0/ibm-plex-mono.zip" + "url": "https://github.com/IBM/plex/releases/download/@ibm%2Fplex-mono@1.1.0/ibm-plex-mono.zip" }, "original": { "type": "tarball", - "url": "https://github.com/IBM/plex/releases/download/@ibm/plex-mono@1.1.0/ibm-plex-mono.zip" + "url": "https://github.com/IBM/plex/releases/download/@ibm%2Fplex-mono@1.1.0/ibm-plex-mono.zip" } }, "indigo": { diff --git a/appview/db/db.go b/appview/db/db.go --- a/appview/db/db.go +++ b/appview/db/db.go @@ -559,6 +559,15 @@ pull_id integer references pulls(id) ); + create table if not exists recent_links ( + id integer primary key autoincrement, + user_did text not null, + link_type text not null, + target text not null, + visited text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + unique(user_did, target) + ); + create table if not exists notification_preferences ( id integer primary key autoincrement, user_did text not null unique, diff --git a/appview/db/recents.go b/appview/db/recents.go new file mode 100644 --- /dev/null +++ b/appview/db/recents.go @@ -0,0 +1,90 @@ +package db + +import ( + "context" + "fmt" + "time" + + "tangled.org/core/appview/models" + "tangled.org/core/orm" +) + +func UpsertRecentLink(e Execer, userDid string, linkType models.RecentLinkType, target string) error { + _, err := e.Exec(` + insert into recent_links (user_did, link_type, target, visited) + values (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + on conflict(user_did, target) do update set + visited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + `, userDid, string(linkType), target) + if err != nil { + return fmt.Errorf("failed to upsert recent link: %w", err) + } + + _, err = e.Exec(` + delete from recent_links + where user_did = ? + and id not in ( + select id from recent_links + where user_did = ? + order by visited desc + limit 5 + ) + `, userDid, userDid) + if err != nil { + return fmt.Errorf("failed to trim recent links: %w", err) + } + + return nil +} + +func GetRecentLinks(e Execer, filters ...orm.Filter) ([]*models.RecentLink, 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 + } + } + + args = append(args, 5) + + query := fmt.Sprintf(` + select id, user_did, link_type, target, visited + from recent_links + %s + order by visited desc + limit ? + `, whereClause) + + rows, err := e.QueryContext(context.Background(), query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query recent links: %w", err) + } + defer rows.Close() + + var links []*models.RecentLink + for rows.Next() { + var l models.RecentLink + var linkTypeStr string + var visitedStr string + if err := rows.Scan(&l.Id, &l.UserDid, &linkTypeStr, &l.Target, &visitedStr); err != nil { + return nil, fmt.Errorf("failed to scan recent link: %w", err) + } + l.LinkType = models.RecentLinkType(linkTypeStr) + l.Visited, err = time.Parse(time.RFC3339, visitedStr) + if err != nil { + return nil, fmt.Errorf("failed to parse visited timestamp: %w", err) + } + links = append(links, &l) + } + + return links, nil +} diff --git a/appview/db/recents_test.go b/appview/db/recents_test.go new file mode 100644 --- /dev/null +++ b/appview/db/recents_test.go @@ -0,0 +1,102 @@ +package db + +import ( + "fmt" + "testing" + + "tangled.org/core/appview/models" + "tangled.org/core/orm" +) + +func insertRecentLink(t *testing.T, d *DB, userDid string, linkType models.RecentLinkType, target, visited string) { + t.Helper() + if _, err := d.Exec( + `insert into recent_links (user_did, link_type, target, visited) values (?, ?, ?, ?)`, + userDid, string(linkType), target, visited, + ); err != nil { + t.Fatalf("insertRecentLink %q: %v", target, err) + } +} + +func TestUpsertRecentLink_LimitFive(t *testing.T) { + d := newTestDB(t) + const userDid = "did:plc:akshay" + + for i := range 6 { + target := fmt.Sprintf("%d-repo-did", i) + if err := UpsertRecentLink(d, userDid, models.RecentLinkTypeRepo, target); err != nil { + t.Fatalf("UpsertRecentLink %d: %v", i, err) + } + } + + if got := countRows(t, d, "select count(*) from recent_links where user_did = ?", userDid); got != 5 { + t.Errorf("recent_links count = %d, want 5", got) + } +} + +func TestUpsertRecentLink_DeduplicatesAndUpdatesTimestamp(t *testing.T) { + d := newTestDB(t) + const userDid = "did:plc:akshay" + const target = "at://did:plc:akshay/sh.tangled.repo/myrepo" + + insertRecentLink(t, d, userDid, models.RecentLinkTypeIssue, target, "2024-01-01T00:00:00Z") + + if err := UpsertRecentLink(d, userDid, models.RecentLinkTypeIssue, target); err != nil { + t.Fatalf("upsert: %v", err) + } + + if got := countRows(t, d, "select count(*) from recent_links where user_did = ? and target = ?", userDid, target); got != 1 { + t.Errorf("expected 1 row after duplicate upsert, got %d", got) + } + + var visited string + if err := d.QueryRow("select visited from recent_links where user_did = ? and target = ?", userDid, target).Scan(&visited); err != nil { + t.Fatalf("scan visited: %v", err) + } + if visited <= "2024-01-01T00:00:00Z" { + t.Errorf("visited not updated: got %q, want > %q", visited, "2024-01-01T00:00:00Z") + } +} + +func TestGetRecentLinks_LessThanFive(t *testing.T) { + d := newTestDB(t) + const userDid = "did:plc:akshay" + + for i := range 6 { + name := fmt.Sprintf("repo-%d", i) + insertRecentLink(t, d, userDid, models.RecentLinkTypeRepo, name, "2024-01-01T00:00:00Z") + } + + links, err := GetRecentLinks(d, orm.FilterEq("user_did", userDid)) + if err != nil { + t.Fatalf("GetRecentLinks: %v", err) + } + + if len(links) > 5 { + t.Errorf("GetRecentLinks returned %d links, want <5", len(links)) + } +} + +func TestGetRecentLinks_OrderedByMostRecent(t *testing.T) { + d := newTestDB(t) + const userDid = "did:plc:akshay" + + insertRecentLink(t, d, userDid, models.RecentLinkTypeRepo, "repo-first", "2024-01-01T00:00:00Z") + insertRecentLink(t, d, userDid, models.RecentLinkTypeRepo, "repo-second", "2024-01-02T00:00:00Z") + insertRecentLink(t, d, userDid, models.RecentLinkTypeRepo, "repo-third", "2024-01-03T00:00:00Z") + + links, err := GetRecentLinks(d, orm.FilterEq("user_did", userDid)) + if err != nil { + t.Fatalf("GetRecentLinks: %v", err) + } + + if len(links) != 3 { + t.Fatalf("expected 3 links, got %d", len(links)) + } + if links[0].Target != "repo-third" { + t.Errorf("links[0].Target = %q, want %q", links[0].Target, "repo-third") + } + if links[2].Target != "repo-first" { + t.Errorf("links[2].Target = %q, want %q", links[2].Target, "repo-first") + } +}