From 01e5086eb30e5c6cf7064c430a84b3b61919fd6d Mon Sep 17 00:00:00 2001 From: scanash00 Date: Mon, 19 Jan 2026 03:23:58 -0900 Subject: [PATCH] Various optimizations and caching implemented --- backend/internal/api/cache.go | 48 + backend/internal/api/hydration.go | 249 ++++-- backend/internal/db/db.go | 1 + backend/internal/db/queries.go | 882 +------------------ backend/internal/db/queries_annotations.go | 172 ++++ backend/internal/db/queries_bookmarks.go | 176 ++++ backend/internal/db/queries_collections.go | 172 ++++ backend/internal/db/queries_highlights.go | 201 +++++ backend/internal/db/queries_history.go | 36 + backend/internal/db/queries_keys.go | 59 ++ backend/internal/db/queries_likes.go | 105 +++ backend/internal/db/queries_notifications.go | 52 ++ backend/internal/db/queries_replies.go | 176 ++++ backend/internal/db/queries_sessions.go | 32 + 14 files changed, 1405 insertions(+), 956 deletions(-) create mode 100644 backend/internal/api/cache.go create mode 100644 backend/internal/db/queries_annotations.go create mode 100644 backend/internal/db/queries_bookmarks.go create mode 100644 backend/internal/db/queries_collections.go create mode 100644 backend/internal/db/queries_highlights.go create mode 100644 backend/internal/db/queries_history.go create mode 100644 backend/internal/db/queries_keys.go create mode 100644 backend/internal/db/queries_likes.go create mode 100644 backend/internal/db/queries_notifications.go create mode 100644 backend/internal/db/queries_replies.go create mode 100644 backend/internal/db/queries_sessions.go diff --git a/backend/internal/api/cache.go b/backend/internal/api/cache.go new file mode 100644 index 0000000..6b306cd --- /dev/null +++ b/backend/internal/api/cache.go @@ -0,0 +1,48 @@ +package api + +import ( + "sync" + "time" +) + +type ProfileCache interface { + Get(did string) (Author, bool) + Set(did string, profile Author) +} +type InMemoryCache struct { + cache sync.Map + ttl time.Duration +} + +type cachedProfile struct { + Author Author + ExpiresAt time.Time +} + +func NewInMemoryCache(ttl time.Duration) *InMemoryCache { + return &InMemoryCache{ + ttl: ttl, + } +} + +func (c *InMemoryCache) Get(did string) (Author, bool) { + val, ok := c.cache.Load(did) + if !ok { + return Author{}, false + } + + entry := val.(cachedProfile) + if time.Now().After(entry.ExpiresAt) { + c.cache.Delete(did) + return Author{}, false + } + + return entry.Author, true +} + +func (c *InMemoryCache) Set(did string, profile Author) { + c.cache.Store(did, cachedProfile{ + Author: profile, + ExpiresAt: time.Now().Add(c.ttl), + }) +} diff --git a/backend/internal/api/hydration.go b/backend/internal/api/hydration.go index 8f4cdcd..07a9092 100644 --- a/backend/internal/api/hydration.go +++ b/backend/internal/api/hydration.go @@ -13,6 +13,10 @@ import ( "margin.at/internal/db" ) +var ( + Cache ProfileCache = NewInMemoryCache(5 * time.Minute) +) + type Author struct { DID string `json:"did"` Handle string `json:"handle"` @@ -148,6 +152,23 @@ func hydrateAnnotations(database *db.DB, annotations []db.Annotation, viewerDID profiles := fetchProfilesForDIDs(collectDIDs(annotations, func(a db.Annotation) string { return a.AuthorDID })) + var likeCounts map[string]int + var replyCounts map[string]int + var viewerLikes map[string]bool + + if database != nil { + uris := make([]string, len(annotations)) + for i, a := range annotations { + uris[i] = a.URI + } + + likeCounts, _ = database.GetLikeCounts(uris) + replyCounts, _ = database.GetReplyCounts(uris) + if viewerDID != "" { + viewerLikes, _ = database.GetViewerLikes(viewerDID, uris) + } + } + result := make([]APIAnnotation, len(annotations)) for i, a := range annotations { var body *APIBody @@ -208,12 +229,10 @@ func hydrateAnnotations(database *db.DB, annotations []db.Annotation, viewerDID } if database != nil { - result[i].LikeCount, _ = database.GetLikeCount(a.URI) - result[i].ReplyCount, _ = database.GetReplyCount(a.URI) - if viewerDID != "" { - if _, err := database.GetLikeByUserAndSubject(viewerDID, a.URI); err == nil { - result[i].ViewerHasLiked = true - } + result[i].LikeCount = likeCounts[a.URI] + result[i].ReplyCount = replyCounts[a.URI] + if viewerLikes != nil && viewerLikes[a.URI] { + result[i].ViewerHasLiked = true } } } @@ -228,6 +247,23 @@ func hydrateHighlights(database *db.DB, highlights []db.Highlight, viewerDID str profiles := fetchProfilesForDIDs(collectDIDs(highlights, func(h db.Highlight) string { return h.AuthorDID })) + var likeCounts map[string]int + var replyCounts map[string]int + var viewerLikes map[string]bool + + if database != nil { + uris := make([]string, len(highlights)) + for i, h := range highlights { + uris[i] = h.URI + } + + likeCounts, _ = database.GetLikeCounts(uris) + replyCounts, _ = database.GetReplyCounts(uris) + if viewerDID != "" { + viewerLikes, _ = database.GetViewerLikes(viewerDID, uris) + } + } + result := make([]APIHighlight, len(highlights)) for i, h := range highlights { var selector *APISelector @@ -272,12 +308,10 @@ func hydrateHighlights(database *db.DB, highlights []db.Highlight, viewerDID str } if database != nil { - result[i].LikeCount, _ = database.GetLikeCount(h.URI) - result[i].ReplyCount, _ = database.GetReplyCount(h.URI) - if viewerDID != "" { - if _, err := database.GetLikeByUserAndSubject(viewerDID, h.URI); err == nil { - result[i].ViewerHasLiked = true - } + result[i].LikeCount = likeCounts[h.URI] + result[i].ReplyCount = replyCounts[h.URI] + if viewerLikes != nil && viewerLikes[h.URI] { + result[i].ViewerHasLiked = true } } } @@ -292,6 +326,23 @@ func hydrateBookmarks(database *db.DB, bookmarks []db.Bookmark, viewerDID string profiles := fetchProfilesForDIDs(collectDIDs(bookmarks, func(b db.Bookmark) string { return b.AuthorDID })) + var likeCounts map[string]int + var replyCounts map[string]int + var viewerLikes map[string]bool + + if database != nil { + uris := make([]string, len(bookmarks)) + for i, b := range bookmarks { + uris[i] = b.URI + } + + likeCounts, _ = database.GetLikeCounts(uris) + replyCounts, _ = database.GetReplyCounts(uris) + if viewerDID != "" { + viewerLikes, _ = database.GetViewerLikes(viewerDID, uris) + } + } + result := make([]APIBookmark, len(bookmarks)) for i, b := range bookmarks { var tags []string @@ -326,12 +377,10 @@ func hydrateBookmarks(database *db.DB, bookmarks []db.Bookmark, viewerDID string CID: cid, } if database != nil { - result[i].LikeCount, _ = database.GetLikeCount(b.URI) - result[i].ReplyCount, _ = database.GetReplyCount(b.URI) - if viewerDID != "" { - if _, err := database.GetLikeByUserAndSubject(viewerDID, b.URI); err == nil { - result[i].ViewerHasLiked = true - } + result[i].LikeCount = likeCounts[b.URI] + result[i].ReplyCount = replyCounts[b.URI] + if viewerLikes != nil && viewerLikes[b.URI] { + result[i].ViewerHasLiked = true } } } @@ -388,15 +437,17 @@ func collectDIDs[T any](items []T, getDID func(T) string) []string { func fetchProfilesForDIDs(dids []string) map[string]Author { profiles := make(map[string]Author) + missingDIDs := make([]string, 0) for _, did := range dids { - profiles[did] = Author{ - DID: did, - Handle: "unknown", + if author, ok := Cache.Get(did); ok { + profiles[did] = author + } else { + missingDIDs = append(missingDIDs, did) } } - if len(dids) == 0 { + if len(missingDIDs) == 0 { return profiles } @@ -404,12 +455,12 @@ func fetchProfilesForDIDs(dids []string) map[string]Author { var wg sync.WaitGroup var mu sync.Mutex - for i := 0; i < len(dids); i += batchSize { + for i := 0; i < len(missingDIDs); i += batchSize { end := i + batchSize - if end > len(dids) { - end = len(dids) + if end > len(missingDIDs) { + end = len(missingDIDs) } - batch := dids[i:end] + batch := missingDIDs[i:end] wg.Add(1) go func(actors []string) { @@ -417,10 +468,11 @@ func fetchProfilesForDIDs(dids []string) map[string]Author { fetched, err := fetchProfiles(actors) if err == nil { mu.Lock() + defer mu.Unlock() for k, v := range fetched { profiles[k] = v + Cache.Set(k, v) } - mu.Unlock() } }(batch) } @@ -484,6 +536,82 @@ func hydrateCollectionItems(database *db.DB, items []db.CollectionItem, viewerDI profiles := fetchProfilesForDIDs(collectDIDs(items, func(i db.CollectionItem) string { return i.AuthorDID })) + var collectionURIs []string + var annotationURIs []string + var highlightURIs []string + var bookmarkURIs []string + + for _, item := range items { + collectionURIs = append(collectionURIs, item.CollectionURI) + if strings.Contains(item.AnnotationURI, "at.margin.annotation") { + annotationURIs = append(annotationURIs, item.AnnotationURI) + } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { + highlightURIs = append(highlightURIs, item.AnnotationURI) + } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { + bookmarkURIs = append(bookmarkURIs, item.AnnotationURI) + } + } + + collectionsMap := make(map[string]APICollection) + if len(collectionURIs) > 0 { + colls, err := database.GetCollectionsByURIs(collectionURIs) + if err == nil { + collProfiles := fetchProfilesForDIDs(collectDIDs(colls, func(c db.Collection) string { return c.AuthorDID })) + for _, coll := range colls { + icon := "" + if coll.Icon != nil { + icon = *coll.Icon + } + desc := "" + if coll.Description != nil { + desc = *coll.Description + } + collectionsMap[coll.URI] = APICollection{ + URI: coll.URI, + Name: coll.Name, + Description: desc, + Icon: icon, + Creator: collProfiles[coll.AuthorDID], + CreatedAt: coll.CreatedAt, + IndexedAt: coll.IndexedAt, + } + } + } + } + + annotationsMap := make(map[string]APIAnnotation) + if len(annotationURIs) > 0 { + rawAnnos, err := database.GetAnnotationsByURIs(annotationURIs) + if err == nil { + hydrated, _ := hydrateAnnotations(database, rawAnnos, viewerDID) + for _, a := range hydrated { + annotationsMap[a.ID] = a + } + } + } + + highlightsMap := make(map[string]APIHighlight) + if len(highlightURIs) > 0 { + rawHighlights, err := database.GetHighlightsByURIs(highlightURIs) + if err == nil { + hydrated, _ := hydrateHighlights(database, rawHighlights, viewerDID) + for _, h := range hydrated { + highlightsMap[h.ID] = h + } + } + } + + bookmarksMap := make(map[string]APIBookmark) + if len(bookmarkURIs) > 0 { + rawBookmarks, err := database.GetBookmarksByURIs(bookmarkURIs) + if err == nil { + hydrated, _ := hydrateBookmarks(database, rawBookmarks, viewerDID) + for _, b := range hydrated { + bookmarksMap[b.ID] = b + } + } + } + result := make([]APICollectionItem, len(items)) for i, item := range items { apiItem := APICollectionItem{ @@ -495,52 +623,16 @@ func hydrateCollectionItems(database *db.DB, items []db.CollectionItem, viewerDI Position: item.Position, } - if coll, err := database.GetCollectionByURI(item.CollectionURI); err == nil { - icon := "" - if coll.Icon != nil { - icon = *coll.Icon - } - desc := "" - if coll.Description != nil { - desc = *coll.Description - } - apiItem.Collection = &APICollection{ - URI: coll.URI, - Name: coll.Name, - Description: desc, - Icon: icon, - Creator: profiles[coll.AuthorDID], - CreatedAt: coll.CreatedAt, - IndexedAt: coll.IndexedAt, - } + if coll, ok := collectionsMap[item.CollectionURI]; ok { + apiItem.Collection = &coll } - if strings.Contains(item.AnnotationURI, "at.margin.annotation") { - if a, err := database.GetAnnotationByURI(item.AnnotationURI); err == nil { - hydrated, _ := hydrateAnnotations(database, []db.Annotation{*a}, viewerDID) - if len(hydrated) > 0 { - apiItem.Annotation = &hydrated[0] - } - } - } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { - if h, err := database.GetHighlightByURI(item.AnnotationURI); err == nil { - hydrated, _ := hydrateHighlights(database, []db.Highlight{*h}, viewerDID) - if len(hydrated) > 0 { - apiItem.Highlight = &hydrated[0] - } - } - } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { - if b, err := database.GetBookmarkByURI(item.AnnotationURI); err == nil { - hydrated, _ := hydrateBookmarks(database, []db.Bookmark{*b}, viewerDID) - if len(hydrated) > 0 { - apiItem.Bookmark = &hydrated[0] - } else { - log.Printf("Failed to hydrate bookmark %s: empty hydration result\n", item.AnnotationURI) - } - } else { - } - } else { - log.Printf("Unknown item type for URI: %s\n", item.AnnotationURI) + if val, ok := annotationsMap[item.AnnotationURI]; ok { + apiItem.Annotation = &val + } else if val, ok := highlightsMap[item.AnnotationURI]; ok { + apiItem.Highlight = &val + } else if val, ok := bookmarksMap[item.AnnotationURI]; ok { + apiItem.Bookmark = &val } result[i] = apiItem @@ -577,18 +669,13 @@ func hydrateNotifications(database *db.DB, notifications []db.Notification) ([]A replyMap := make(map[string]APIReply) if len(replyURIs) > 0 { - var replies []db.Reply - for _, uri := range replyURIs { - r, err := database.GetReplyByURI(uri) - if err == nil { - replies = append(replies, *r) + replies, err := database.GetRepliesByURIs(replyURIs) + if err == nil { + hydratedReplies, _ := hydrateReplies(replies) + for _, r := range hydratedReplies { + replyMap[r.ID] = r } } - - hydratedReplies, _ := hydrateReplies(replies) - for _, r := range hydratedReplies { - replyMap[r.ID] = r - } } result := make([]APINotification, len(notifications)) diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 713991b..2e6d6eb 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -241,6 +241,7 @@ func (db *DB) Migrate() error { )`) db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_subject_uri ON likes(subject_uri)`) db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_author_did ON likes(author_did)`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_author_subject ON likes(author_did, subject_uri)`) db.Exec(`CREATE TABLE IF NOT EXISTS collections ( uri TEXT PRIMARY KEY, diff --git a/backend/internal/db/queries.go b/backend/internal/db/queries.go index 08e4dfd..ea6fe8f 100644 --- a/backend/internal/db/queries.go +++ b/backend/internal/db/queries.go @@ -10,149 +10,6 @@ import ( "time" ) -func (db *DB) CreateAnnotation(a *Annotation) error { - _, err := db.Exec(db.Rebind(` - INSERT INTO annotations (uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(uri) DO UPDATE SET - motivation = excluded.motivation, - body_value = excluded.body_value, - body_format = excluded.body_format, - body_uri = excluded.body_uri, - target_title = excluded.target_title, - selector_json = excluded.selector_json, - tags_json = excluded.tags_json, - indexed_at = excluded.indexed_at, - cid = excluded.cid - `), a.URI, a.AuthorDID, a.Motivation, a.BodyValue, a.BodyFormat, a.BodyURI, a.TargetSource, a.TargetHash, a.TargetTitle, a.SelectorJSON, a.TagsJSON, a.CreatedAt, a.IndexedAt, a.CID) - return err -} - -func (db *DB) GetAnnotationByURI(uri string) (*Annotation, error) { - var a Annotation - err := db.QueryRow(db.Rebind(` - SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid - FROM annotations - WHERE uri = ? - `), uri).Scan(&a.URI, &a.AuthorDID, &a.Motivation, &a.BodyValue, &a.BodyFormat, &a.BodyURI, &a.TargetSource, &a.TargetHash, &a.TargetTitle, &a.SelectorJSON, &a.TagsJSON, &a.CreatedAt, &a.IndexedAt, &a.CID) - if err != nil { - return nil, err - } - return &a, nil -} - -func (db *DB) GetAnnotationsByTargetHash(targetHash string, limit, offset int) ([]Annotation, error) { - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid - FROM annotations - WHERE target_hash = ? - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), targetHash, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - return scanAnnotations(rows) -} - -func (db *DB) GetAnnotationsByAuthor(authorDID string, limit, offset int) ([]Annotation, error) { - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid - FROM annotations - WHERE author_did = ? - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), authorDID, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - return scanAnnotations(rows) -} - -func (db *DB) GetAnnotationsByMotivation(motivation string, limit, offset int) ([]Annotation, error) { - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid - FROM annotations - WHERE motivation = ? - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), motivation, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - return scanAnnotations(rows) -} - -func (db *DB) GetRecentAnnotations(limit, offset int) ([]Annotation, error) { - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid - FROM annotations - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - return scanAnnotations(rows) -} - -func (db *DB) GetAnnotationsByTag(tag string, limit, offset int) ([]Annotation, error) { - pattern := "%\"" + tag + "\"%" - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid - FROM annotations - WHERE tags_json LIKE ? - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), pattern, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - return scanAnnotations(rows) -} - -func (db *DB) DeleteAnnotation(uri string) error { - _, err := db.Exec(db.Rebind(`DELETE FROM annotations WHERE uri = ?`), uri) - return err -} - -func (db *DB) UpdateAnnotation(uri, bodyValue, tagsJSON, cid string) error { - _, err := db.Exec(db.Rebind(` - UPDATE annotations - SET body_value = ?, tags_json = ?, cid = ?, indexed_at = ? - WHERE uri = ? - `), bodyValue, tagsJSON, cid, time.Now(), uri) - return err -} - -func (db *DB) UpdateHighlight(uri, color, tagsJSON, cid string) error { - _, err := db.Exec(db.Rebind(` - UPDATE highlights - SET color = ?, tags_json = ?, cid = ?, indexed_at = ? - WHERE uri = ? - `), color, tagsJSON, cid, time.Now(), uri) - return err -} - -func (db *DB) UpdateBookmark(uri, title, description, tagsJSON, cid string) error { - _, err := db.Exec(db.Rebind(` - UPDATE bookmarks - SET title = ?, description = ?, tags_json = ?, cid = ?, indexed_at = ? - WHERE uri = ? - `), title, description, tagsJSON, cid, time.Now(), uri) - return err -} - type EditHistory struct { ID int `json:"id"` URI string `json:"uri"` @@ -162,37 +19,6 @@ type EditHistory struct { EditedAt time.Time `json:"editedAt"` } -func (db *DB) SaveEditHistory(uri, recordType, previousContent string, previousCID *string) error { - _, err := db.Exec(db.Rebind(` - INSERT INTO edit_history (uri, record_type, previous_content, previous_cid, edited_at) - VALUES (?, ?, ?, ?, ?) - `), uri, recordType, previousContent, previousCID, time.Now()) - return err -} - -func (db *DB) GetEditHistory(uri string) ([]EditHistory, error) { - rows, err := db.Query(db.Rebind(` - SELECT id, uri, record_type, previous_content, previous_cid, edited_at - FROM edit_history - WHERE uri = ? - ORDER BY edited_at DESC - `), uri) - if err != nil { - return nil, err - } - defer rows.Close() - - var history []EditHistory - for rows.Next() { - var h EditHistory - if err := rows.Scan(&h.ID, &h.URI, &h.RecordType, &h.PreviousContent, &h.PreviousCID, &h.EditedAt); err != nil { - return nil, err - } - history = append(history, h) - } - return history, nil -} - func scanAnnotations(rows interface { Next() bool Scan(...interface{}) error @@ -208,616 +34,12 @@ func scanAnnotations(rows interface { return annotations, nil } -func (db *DB) CreateHighlight(h *Highlight) error { - _, err := db.Exec(db.Rebind(` - INSERT INTO highlights (uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(uri) DO UPDATE SET - target_title = excluded.target_title, - selector_json = excluded.selector_json, - color = excluded.color, - tags_json = excluded.tags_json, - indexed_at = excluded.indexed_at, - cid = excluded.cid - `), h.URI, h.AuthorDID, h.TargetSource, h.TargetHash, h.TargetTitle, h.SelectorJSON, h.Color, h.TagsJSON, h.CreatedAt, h.IndexedAt, h.CID) - return err -} - -func (db *DB) GetHighlightByURI(uri string) (*Highlight, error) { - var h Highlight - err := db.QueryRow(db.Rebind(` - SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights - WHERE uri = ? - `), uri).Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID) - if err != nil { - return nil, err - } - return &h, nil -} - -func (db *DB) GetRecentHighlights(limit, offset int) ([]Highlight, error) { - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - var highlights []Highlight - for rows.Next() { - var h Highlight - if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { - return nil, err - } - highlights = append(highlights, h) - } - return highlights, nil -} - -func (db *DB) GetHighlightsByTag(tag string, limit, offset int) ([]Highlight, error) { - pattern := "%\"" + tag + "\"%" - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights - WHERE tags_json LIKE ? - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), pattern, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - var highlights []Highlight - for rows.Next() { - var h Highlight - if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { - return nil, err - } - highlights = append(highlights, h) - } - return highlights, nil -} - -func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) { - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - var bookmarks []Bookmark - for rows.Next() { - var b Bookmark - if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { - return nil, err - } - bookmarks = append(bookmarks, b) - } - return bookmarks, nil -} - -func (db *DB) GetBookmarksByTag(tag string, limit, offset int) ([]Bookmark, error) { - pattern := "%\"" + tag + "\"%" - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks - WHERE tags_json LIKE ? - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), pattern, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - var bookmarks []Bookmark - for rows.Next() { - var b Bookmark - if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { - return nil, err - } - bookmarks = append(bookmarks, b) - } - return bookmarks, nil -} - -func (db *DB) GetAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Annotation, error) { - pattern := "%\"" + tag + "\"%" - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid - FROM annotations - WHERE author_did = ? AND tags_json LIKE ? - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), authorDID, pattern, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - return scanAnnotations(rows) -} - -func (db *DB) GetHighlightsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Highlight, error) { - pattern := "%\"" + tag + "\"%" - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights - WHERE author_did = ? AND tags_json LIKE ? - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), authorDID, pattern, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - var highlights []Highlight - for rows.Next() { - var h Highlight - if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { - return nil, err - } - highlights = append(highlights, h) - } - return highlights, nil -} - -func (db *DB) GetBookmarksByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Bookmark, error) { - pattern := "%\"" + tag + "\"%" - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks - WHERE author_did = ? AND tags_json LIKE ? - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), authorDID, pattern, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - var bookmarks []Bookmark - for rows.Next() { - var b Bookmark - if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { - return nil, err - } - bookmarks = append(bookmarks, b) - } - return bookmarks, nil -} - -func (db *DB) GetHighlightsByTargetHash(targetHash string, limit, offset int) ([]Highlight, error) { - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights - WHERE target_hash = ? - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), targetHash, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - var highlights []Highlight - for rows.Next() { - var h Highlight - if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { - return nil, err - } - highlights = append(highlights, h) - } - return highlights, nil -} - -func (db *DB) GetHighlightsByAuthor(authorDID string, limit, offset int) ([]Highlight, error) { - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights - WHERE author_did = ? - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), authorDID, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - var highlights []Highlight - for rows.Next() { - var h Highlight - if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { - return nil, err - } - highlights = append(highlights, h) - } - return highlights, nil -} - -func (db *DB) DeleteHighlight(uri string) error { - _, err := db.Exec(db.Rebind(`DELETE FROM highlights WHERE uri = ?`), uri) - return err -} - -func (db *DB) CreateBookmark(b *Bookmark) error { - _, err := db.Exec(db.Rebind(` - INSERT INTO bookmarks (uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(uri) DO UPDATE SET - title = excluded.title, - description = excluded.description, - tags_json = excluded.tags_json, - indexed_at = excluded.indexed_at, - cid = excluded.cid - `), b.URI, b.AuthorDID, b.Source, b.SourceHash, b.Title, b.Description, b.TagsJSON, b.CreatedAt, b.IndexedAt, b.CID) - return err -} - -func (db *DB) GetBookmarkByURI(uri string) (*Bookmark, error) { - var b Bookmark - err := db.QueryRow(db.Rebind(` - SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks - WHERE uri = ? - `), uri).Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID) - if err != nil { - return nil, err - } - return &b, nil -} - -func (db *DB) GetBookmarksByAuthor(authorDID string, limit, offset int) ([]Bookmark, error) { - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks - WHERE author_did = ? - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), authorDID, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - var bookmarks []Bookmark - for rows.Next() { - var b Bookmark - if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { - return nil, err - } - bookmarks = append(bookmarks, b) - } - return bookmarks, nil -} - -func (db *DB) DeleteBookmark(uri string) error { - _, err := db.Exec(db.Rebind(`DELETE FROM bookmarks WHERE uri = ?`), uri) - return err -} - -func (db *DB) CreateReply(r *Reply) error { - _, err := db.Exec(db.Rebind(` - INSERT INTO replies (uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(uri) DO UPDATE SET - text = excluded.text, - format = excluded.format, - indexed_at = excluded.indexed_at, - cid = excluded.cid - `), r.URI, r.AuthorDID, r.ParentURI, r.RootURI, r.Text, r.Format, r.CreatedAt, r.IndexedAt, r.CID) - return err -} - -func (db *DB) GetRepliesByRoot(rootURI string) ([]Reply, error) { - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid - FROM replies - WHERE root_uri = ? - ORDER BY created_at ASC - `), rootURI) - if err != nil { - return nil, err - } - defer rows.Close() - - var replies []Reply - for rows.Next() { - var r Reply - if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { - return nil, err - } - replies = append(replies, r) - } - return replies, nil -} - -func (db *DB) GetReplyByURI(uri string) (*Reply, error) { - var r Reply - err := db.QueryRow(db.Rebind(` - SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid - FROM replies - WHERE uri = ? - `), uri).Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID) - if err != nil { - return nil, err - } - return &r, nil -} - -func (db *DB) DeleteReply(uri string) error { - _, err := db.Exec(db.Rebind(`DELETE FROM replies WHERE uri = ?`), uri) - return err -} - -func (db *DB) GetRepliesByAuthor(authorDID string) ([]Reply, error) { - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid - FROM replies - WHERE author_did = ? - ORDER BY created_at DESC - `), authorDID) - if err != nil { - return nil, err - } - defer rows.Close() - - var replies []Reply - for rows.Next() { - var r Reply - if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { - return nil, err - } - replies = append(replies, r) - } - return replies, nil -} - func (db *DB) AnnotationExists(uri string) bool { var count int db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM annotations WHERE uri = ?`), uri).Scan(&count) return count > 0 } -func (db *DB) GetOrphanedRepliesByAuthor(authorDID string) ([]Reply, error) { - rows, err := db.Query(db.Rebind(` - SELECT r.uri, r.author_did, r.parent_uri, r.root_uri, r.text, r.format, r.created_at, r.indexed_at, r.cid - FROM replies r - LEFT JOIN annotations a ON r.root_uri = a.uri - WHERE r.author_did = ? AND a.uri IS NULL - `), authorDID) - if err != nil { - return nil, err - } - defer rows.Close() - - var replies []Reply - for rows.Next() { - var r Reply - if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { - return nil, err - } - replies = append(replies, r) - } - return replies, nil -} - -func (db *DB) CreateLike(l *Like) error { - _, err := db.Exec(db.Rebind(` - INSERT INTO likes (uri, author_did, subject_uri, created_at, indexed_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(uri) DO NOTHING - `), l.URI, l.AuthorDID, l.SubjectURI, l.CreatedAt, l.IndexedAt) - return err -} - -func (db *DB) DeleteLike(uri string) error { - _, err := db.Exec(db.Rebind(`DELETE FROM likes WHERE uri = ?`), uri) - return err -} - -func (db *DB) GetLikeCount(subjectURI string) (int, error) { - var count int - err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM likes WHERE subject_uri = ?`), subjectURI).Scan(&count) - return count, err -} - -func (db *DB) GetReplyCount(rootURI string) (int, error) { - var count int - err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM replies WHERE root_uri = ?`), rootURI).Scan(&count) - return count, err -} - -func (db *DB) GetLikeByUserAndSubject(userDID, subjectURI string) (*Like, error) { - var like Like - err := db.QueryRow(db.Rebind(` - SELECT uri, author_did, subject_uri, created_at, indexed_at - FROM likes - WHERE author_did = ? AND subject_uri = ? - `), userDID, subjectURI).Scan(&like.URI, &like.AuthorDID, &like.SubjectURI, &like.CreatedAt, &like.IndexedAt) - if err != nil { - return nil, err - } - return &like, nil -} - -func (db *DB) CreateCollection(c *Collection) error { - _, err := db.Exec(db.Rebind(` - INSERT INTO collections (uri, author_did, name, description, icon, created_at, indexed_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(uri) DO UPDATE SET - name = excluded.name, - description = excluded.description, - icon = excluded.icon, - indexed_at = excluded.indexed_at - `), c.URI, c.AuthorDID, c.Name, c.Description, c.Icon, c.CreatedAt, c.IndexedAt) - return err -} - -func (db *DB) GetCollectionsByAuthor(authorDID string) ([]Collection, error) { - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, name, description, icon, created_at, indexed_at - FROM collections - WHERE author_did = ? - ORDER BY created_at DESC - `), authorDID) - if err != nil { - return nil, err - } - defer rows.Close() - - var collections []Collection - for rows.Next() { - var c Collection - if err := rows.Scan(&c.URI, &c.AuthorDID, &c.Name, &c.Description, &c.Icon, &c.CreatedAt, &c.IndexedAt); err != nil { - return nil, err - } - collections = append(collections, c) - } - return collections, nil -} - -func (db *DB) GetCollectionByURI(uri string) (*Collection, error) { - var c Collection - err := db.QueryRow(db.Rebind(` - SELECT uri, author_did, name, description, icon, created_at, indexed_at - FROM collections - WHERE uri = ? - `), uri).Scan(&c.URI, &c.AuthorDID, &c.Name, &c.Description, &c.Icon, &c.CreatedAt, &c.IndexedAt) - if err != nil { - return nil, err - } - return &c, nil -} - -func (db *DB) DeleteCollection(uri string) error { - - db.Exec(db.Rebind(`DELETE FROM collection_items WHERE collection_uri = ?`), uri) - _, err := db.Exec(db.Rebind(`DELETE FROM collections WHERE uri = ?`), uri) - return err -} - -func (db *DB) AddToCollection(item *CollectionItem) error { - _, err := db.Exec(db.Rebind(` - INSERT INTO collection_items (uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(uri) DO UPDATE SET - position = excluded.position, - indexed_at = excluded.indexed_at - `), item.URI, item.AuthorDID, item.CollectionURI, item.AnnotationURI, item.Position, item.CreatedAt, item.IndexedAt) - return err -} - -func (db *DB) GetCollectionItems(collectionURI string) ([]CollectionItem, error) { - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at - FROM collection_items - WHERE collection_uri = ? - ORDER BY position ASC, created_at DESC - `), collectionURI) - if err != nil { - return nil, err - } - defer rows.Close() - - var items []CollectionItem - for rows.Next() { - var item CollectionItem - if err := rows.Scan(&item.URI, &item.AuthorDID, &item.CollectionURI, &item.AnnotationURI, &item.Position, &item.CreatedAt, &item.IndexedAt); err != nil { - return nil, err - } - items = append(items, item) - } - return items, nil -} - -func (db *DB) RemoveFromCollection(uri string) error { - _, err := db.Exec(db.Rebind(`DELETE FROM collection_items WHERE uri = ?`), uri) - return err -} - -func (db *DB) GetRecentCollectionItems(limit, offset int) ([]CollectionItem, error) { - rows, err := db.Query(db.Rebind(` - SELECT uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at - FROM collection_items - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - var items []CollectionItem - for rows.Next() { - var item CollectionItem - if err := rows.Scan(&item.URI, &item.AuthorDID, &item.CollectionURI, &item.AnnotationURI, &item.Position, &item.CreatedAt, &item.IndexedAt); err != nil { - return nil, err - } - items = append(items, item) - } - return items, nil -} - -func (db *DB) GetCollectionURIsForAnnotation(annotationURI string) ([]string, error) { - rows, err := db.Query(db.Rebind(` - SELECT collection_uri FROM collection_items WHERE annotation_uri = ? - `), annotationURI) - if err != nil { - return nil, err - } - defer rows.Close() - - var uris []string - for rows.Next() { - var uri string - if err := rows.Scan(&uri); err != nil { - return nil, err - } - uris = append(uris, uri) - } - return uris, nil -} - -func (db *DB) SaveSession(id, did, handle, accessToken, refreshToken, dpopKey string, expiresAt time.Time) error { - _, err := db.Exec(db.Rebind(` - INSERT INTO sessions (id, did, handle, access_token, refresh_token, dpop_key, created_at, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - access_token = excluded.access_token, - refresh_token = excluded.refresh_token, - dpop_key = excluded.dpop_key, - expires_at = excluded.expires_at - `), id, did, handle, accessToken, refreshToken, dpopKey, time.Now(), expiresAt) - return err -} - -func (db *DB) GetSession(id string) (did, handle, accessToken, refreshToken, dpopKey string, err error) { - err = db.QueryRow(db.Rebind(` - SELECT did, handle, access_token, refresh_token, COALESCE(dpop_key, '') - FROM sessions - WHERE id = ? AND expires_at > ? - `), id, time.Now()).Scan(&did, &handle, &accessToken, &refreshToken, &dpopKey) - return -} - -func (db *DB) DeleteSession(id string) error { - _, err := db.Exec(db.Rebind(`DELETE FROM sessions WHERE id = ?`), id) - return err -} - func HashURL(rawURL string) string { parsed, err := url.Parse(rawURL) if err != nil { @@ -844,53 +66,6 @@ func ToJSON(v interface{}) string { return string(b) } -func (db *DB) CreateNotification(n *Notification) error { - _, err := db.Exec(db.Rebind(` - INSERT INTO notifications (recipient_did, actor_did, type, subject_uri, created_at) - VALUES (?, ?, ?, ?, ?) - `), n.RecipientDID, n.ActorDID, n.Type, n.SubjectURI, n.CreatedAt) - return err -} - -func (db *DB) GetNotifications(recipientDID string, limit, offset int) ([]Notification, error) { - rows, err := db.Query(db.Rebind(` - SELECT id, recipient_did, actor_did, type, subject_uri, created_at, read_at - FROM notifications - WHERE recipient_did = ? - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `), recipientDID, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - var notifications []Notification - for rows.Next() { - var n Notification - if err := rows.Scan(&n.ID, &n.RecipientDID, &n.ActorDID, &n.Type, &n.SubjectURI, &n.CreatedAt, &n.ReadAt); err != nil { - continue - } - notifications = append(notifications, n) - } - return notifications, nil -} - -func (db *DB) GetUnreadNotificationCount(recipientDID string) (int, error) { - var count int - err := db.QueryRow(db.Rebind(` - SELECT COUNT(*) FROM notifications WHERE recipient_did = ? AND read_at IS NULL - `), recipientDID).Scan(&count) - return count, err -} - -func (db *DB) MarkNotificationsRead(recipientDID string) error { - _, err := db.Exec(db.Rebind(` - UPDATE notifications SET read_at = ? WHERE recipient_did = ? AND read_at IS NULL - `), time.Now(), recipientDID) - return err -} - func (db *DB) GetAuthorByURI(uri string) (string, error) { var authorDID string err := db.QueryRow(db.Rebind(`SELECT author_did FROM annotations WHERE uri = ?`), uri).Scan(&authorDID) @@ -911,56 +86,13 @@ func (db *DB) GetAuthorByURI(uri string) (string, error) { return "", fmt.Errorf("uri not found or no author") } -func (db *DB) CreateAPIKey(key *APIKey) error { - _, err := db.Exec(db.Rebind(` - INSERT INTO api_keys (id, owner_did, name, key_hash, created_at) - VALUES (?, ?, ?, ?, ?) - `), key.ID, key.OwnerDID, key.Name, key.KeyHash, key.CreatedAt) - return err -} - -func (db *DB) GetAPIKeysByOwner(ownerDID string) ([]APIKey, error) { - rows, err := db.Query(db.Rebind(` - SELECT id, owner_did, name, key_hash, created_at, last_used_at - FROM api_keys - WHERE owner_did = ? - ORDER BY created_at DESC - `), ownerDID) - if err != nil { - return nil, err +func buildPlaceholders(n int) string { + if n == 0 { + return "" } - defer rows.Close() - - var keys []APIKey - for rows.Next() { - var k APIKey - if err := rows.Scan(&k.ID, &k.OwnerDID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsedAt); err != nil { - return nil, err - } - keys = append(keys, k) + placeholders := make([]string, n) + for i := range placeholders { + placeholders[i] = "?" } - return keys, nil -} - -func (db *DB) GetAPIKeyByHash(keyHash string) (*APIKey, error) { - var k APIKey - err := db.QueryRow(db.Rebind(` - SELECT id, owner_did, name, key_hash, created_at, last_used_at - FROM api_keys - WHERE key_hash = ? - `), keyHash).Scan(&k.ID, &k.OwnerDID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsedAt) - if err != nil { - return nil, err - } - return &k, nil -} - -func (db *DB) DeleteAPIKey(id, ownerDID string) error { - _, err := db.Exec(db.Rebind(`DELETE FROM api_keys WHERE id = ? AND owner_did = ?`), id, ownerDID) - return err -} - -func (db *DB) UpdateAPIKeyLastUsed(id string) error { - _, err := db.Exec(db.Rebind(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`), time.Now(), id) - return err + return strings.Join(placeholders, ", ") } diff --git a/backend/internal/db/queries_annotations.go b/backend/internal/db/queries_annotations.go new file mode 100644 index 0000000..0aac7cf --- /dev/null +++ b/backend/internal/db/queries_annotations.go @@ -0,0 +1,172 @@ +package db + +import ( + "time" +) + +func (db *DB) CreateAnnotation(a *Annotation) error { + _, err := db.Exec(db.Rebind(` + INSERT INTO annotations (uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(uri) DO UPDATE SET + motivation = excluded.motivation, + body_value = excluded.body_value, + body_format = excluded.body_format, + body_uri = excluded.body_uri, + target_title = excluded.target_title, + selector_json = excluded.selector_json, + tags_json = excluded.tags_json, + indexed_at = excluded.indexed_at, + cid = excluded.cid + `), a.URI, a.AuthorDID, a.Motivation, a.BodyValue, a.BodyFormat, a.BodyURI, a.TargetSource, a.TargetHash, a.TargetTitle, a.SelectorJSON, a.TagsJSON, a.CreatedAt, a.IndexedAt, a.CID) + return err +} + +func (db *DB) GetAnnotationByURI(uri string) (*Annotation, error) { + var a Annotation + err := db.QueryRow(db.Rebind(` + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + FROM annotations + WHERE uri = ? + `), uri).Scan(&a.URI, &a.AuthorDID, &a.Motivation, &a.BodyValue, &a.BodyFormat, &a.BodyURI, &a.TargetSource, &a.TargetHash, &a.TargetTitle, &a.SelectorJSON, &a.TagsJSON, &a.CreatedAt, &a.IndexedAt, &a.CID) + if err != nil { + return nil, err + } + return &a, nil +} + +func (db *DB) GetAnnotationsByTargetHash(targetHash string, limit, offset int) ([]Annotation, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + FROM annotations + WHERE target_hash = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), targetHash, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanAnnotations(rows) +} + +func (db *DB) GetAnnotationsByAuthor(authorDID string, limit, offset int) ([]Annotation, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + FROM annotations + WHERE author_did = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), authorDID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanAnnotations(rows) +} + +func (db *DB) GetAnnotationsByMotivation(motivation string, limit, offset int) ([]Annotation, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + FROM annotations + WHERE motivation = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), motivation, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanAnnotations(rows) +} + +func (db *DB) GetRecentAnnotations(limit, offset int) ([]Annotation, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + FROM annotations + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanAnnotations(rows) +} + +func (db *DB) GetAnnotationsByTag(tag string, limit, offset int) ([]Annotation, error) { + pattern := "%\"" + tag + "\"%" + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + FROM annotations + WHERE tags_json LIKE ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), pattern, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanAnnotations(rows) +} + +func (db *DB) DeleteAnnotation(uri string) error { + _, err := db.Exec(db.Rebind(`DELETE FROM annotations WHERE uri = ?`), uri) + return err +} + +func (db *DB) UpdateAnnotation(uri, bodyValue, tagsJSON, cid string) error { + _, err := db.Exec(db.Rebind(` + UPDATE annotations + SET body_value = ?, tags_json = ?, cid = ?, indexed_at = ? + WHERE uri = ? + `), bodyValue, tagsJSON, cid, time.Now(), uri) + return err +} + +func (db *DB) GetAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Annotation, error) { + pattern := "%\"" + tag + "\"%" + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + FROM annotations + WHERE author_did = ? AND tags_json LIKE ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), authorDID, pattern, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanAnnotations(rows) +} + +func (db *DB) GetAnnotationsByURIs(uris []string) ([]Annotation, error) { + if len(uris) == 0 { + return []Annotation{}, nil + } + + query := db.Rebind(` + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + FROM annotations + WHERE uri IN (` + buildPlaceholders(len(uris)) + `) + `) + + args := make([]interface{}, len(uris)) + for i, uri := range uris { + args[i] = uri + } + + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanAnnotations(rows) +} diff --git a/backend/internal/db/queries_bookmarks.go b/backend/internal/db/queries_bookmarks.go new file mode 100644 index 0000000..9863387 --- /dev/null +++ b/backend/internal/db/queries_bookmarks.go @@ -0,0 +1,176 @@ +package db + +import ( + "time" +) + +func (db *DB) CreateBookmark(b *Bookmark) error { + _, err := db.Exec(db.Rebind(` + INSERT INTO bookmarks (uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(uri) DO UPDATE SET + title = excluded.title, + description = excluded.description, + tags_json = excluded.tags_json, + indexed_at = excluded.indexed_at, + cid = excluded.cid + `), b.URI, b.AuthorDID, b.Source, b.SourceHash, b.Title, b.Description, b.TagsJSON, b.CreatedAt, b.IndexedAt, b.CID) + return err +} + +func (db *DB) GetBookmarkByURI(uri string) (*Bookmark, error) { + var b Bookmark + err := db.QueryRow(db.Rebind(` + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid + FROM bookmarks + WHERE uri = ? + `), uri).Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID) + if err != nil { + return nil, err + } + return &b, nil +} + +func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid + FROM bookmarks + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var bookmarks []Bookmark + for rows.Next() { + var b Bookmark + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { + return nil, err + } + bookmarks = append(bookmarks, b) + } + return bookmarks, nil +} + +func (db *DB) GetBookmarksByTag(tag string, limit, offset int) ([]Bookmark, error) { + pattern := "%\"" + tag + "\"%" + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid + FROM bookmarks + WHERE tags_json LIKE ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), pattern, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var bookmarks []Bookmark + for rows.Next() { + var b Bookmark + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { + return nil, err + } + bookmarks = append(bookmarks, b) + } + return bookmarks, nil +} + +func (db *DB) GetBookmarksByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Bookmark, error) { + pattern := "%\"" + tag + "\"%" + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid + FROM bookmarks + WHERE author_did = ? AND tags_json LIKE ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), authorDID, pattern, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var bookmarks []Bookmark + for rows.Next() { + var b Bookmark + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { + return nil, err + } + bookmarks = append(bookmarks, b) + } + return bookmarks, nil +} + +func (db *DB) GetBookmarksByAuthor(authorDID string, limit, offset int) ([]Bookmark, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid + FROM bookmarks + WHERE author_did = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), authorDID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var bookmarks []Bookmark + for rows.Next() { + var b Bookmark + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { + return nil, err + } + bookmarks = append(bookmarks, b) + } + return bookmarks, nil +} + +func (db *DB) DeleteBookmark(uri string) error { + _, err := db.Exec(db.Rebind(`DELETE FROM bookmarks WHERE uri = ?`), uri) + return err +} + +func (db *DB) UpdateBookmark(uri, title, description, tagsJSON, cid string) error { + _, err := db.Exec(db.Rebind(` + UPDATE bookmarks + SET title = ?, description = ?, tags_json = ?, cid = ?, indexed_at = ? + WHERE uri = ? + `), title, description, tagsJSON, cid, time.Now(), uri) + return err +} + +func (db *DB) GetBookmarksByURIs(uris []string) ([]Bookmark, error) { + if len(uris) == 0 { + return []Bookmark{}, nil + } + + query := db.Rebind(` + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid + FROM bookmarks + WHERE uri IN (` + buildPlaceholders(len(uris)) + `) + `) + + args := make([]interface{}, len(uris)) + for i, uri := range uris { + args[i] = uri + } + + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var bookmarks []Bookmark + for rows.Next() { + var b Bookmark + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { + return nil, err + } + bookmarks = append(bookmarks, b) + } + return bookmarks, nil +} diff --git a/backend/internal/db/queries_collections.go b/backend/internal/db/queries_collections.go new file mode 100644 index 0000000..9f79511 --- /dev/null +++ b/backend/internal/db/queries_collections.go @@ -0,0 +1,172 @@ +package db + +func (db *DB) CreateCollection(c *Collection) error { + _, err := db.Exec(db.Rebind(` + INSERT INTO collections (uri, author_did, name, description, icon, created_at, indexed_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(uri) DO UPDATE SET + name = excluded.name, + description = excluded.description, + icon = excluded.icon, + indexed_at = excluded.indexed_at + `), c.URI, c.AuthorDID, c.Name, c.Description, c.Icon, c.CreatedAt, c.IndexedAt) + return err +} + +func (db *DB) GetCollectionsByAuthor(authorDID string) ([]Collection, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, name, description, icon, created_at, indexed_at + FROM collections + WHERE author_did = ? + ORDER BY created_at DESC + `), authorDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var collections []Collection + for rows.Next() { + var c Collection + if err := rows.Scan(&c.URI, &c.AuthorDID, &c.Name, &c.Description, &c.Icon, &c.CreatedAt, &c.IndexedAt); err != nil { + return nil, err + } + collections = append(collections, c) + } + return collections, nil +} + +func (db *DB) GetCollectionByURI(uri string) (*Collection, error) { + var c Collection + err := db.QueryRow(db.Rebind(` + SELECT uri, author_did, name, description, icon, created_at, indexed_at + FROM collections + WHERE uri = ? + `), uri).Scan(&c.URI, &c.AuthorDID, &c.Name, &c.Description, &c.Icon, &c.CreatedAt, &c.IndexedAt) + if err != nil { + return nil, err + } + return &c, nil +} + +func (db *DB) DeleteCollection(uri string) error { + + db.Exec(db.Rebind(`DELETE FROM collection_items WHERE collection_uri = ?`), uri) + _, err := db.Exec(db.Rebind(`DELETE FROM collections WHERE uri = ?`), uri) + return err +} + +func (db *DB) AddToCollection(item *CollectionItem) error { + _, err := db.Exec(db.Rebind(` + INSERT INTO collection_items (uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(uri) DO UPDATE SET + position = excluded.position, + indexed_at = excluded.indexed_at + `), item.URI, item.AuthorDID, item.CollectionURI, item.AnnotationURI, item.Position, item.CreatedAt, item.IndexedAt) + return err +} + +func (db *DB) GetCollectionItems(collectionURI string) ([]CollectionItem, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at + FROM collection_items + WHERE collection_uri = ? + ORDER BY position ASC, created_at DESC + `), collectionURI) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []CollectionItem + for rows.Next() { + var item CollectionItem + if err := rows.Scan(&item.URI, &item.AuthorDID, &item.CollectionURI, &item.AnnotationURI, &item.Position, &item.CreatedAt, &item.IndexedAt); err != nil { + return nil, err + } + items = append(items, item) + } + return items, nil +} + +func (db *DB) RemoveFromCollection(uri string) error { + _, err := db.Exec(db.Rebind(`DELETE FROM collection_items WHERE uri = ?`), uri) + return err +} + +func (db *DB) GetRecentCollectionItems(limit, offset int) ([]CollectionItem, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at + FROM collection_items + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []CollectionItem + for rows.Next() { + var item CollectionItem + if err := rows.Scan(&item.URI, &item.AuthorDID, &item.CollectionURI, &item.AnnotationURI, &item.Position, &item.CreatedAt, &item.IndexedAt); err != nil { + return nil, err + } + items = append(items, item) + } + return items, nil +} + +func (db *DB) GetCollectionURIsForAnnotation(annotationURI string) ([]string, error) { + rows, err := db.Query(db.Rebind(` + SELECT collection_uri FROM collection_items WHERE annotation_uri = ? + `), annotationURI) + if err != nil { + return nil, err + } + defer rows.Close() + + var uris []string + for rows.Next() { + var uri string + if err := rows.Scan(&uri); err != nil { + return nil, err + } + uris = append(uris, uri) + } + return uris, nil +} + +func (db *DB) GetCollectionsByURIs(uris []string) ([]Collection, error) { + if len(uris) == 0 { + return []Collection{}, nil + } + + query := db.Rebind(` + SELECT uri, author_did, name, description, icon, created_at, indexed_at + FROM collections + WHERE uri IN (` + buildPlaceholders(len(uris)) + `) + `) + + args := make([]interface{}, len(uris)) + for i, uri := range uris { + args[i] = uri + } + + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var collections []Collection + for rows.Next() { + var c Collection + if err := rows.Scan(&c.URI, &c.AuthorDID, &c.Name, &c.Description, &c.Icon, &c.CreatedAt, &c.IndexedAt); err != nil { + return nil, err + } + collections = append(collections, c) + } + return collections, nil +} diff --git a/backend/internal/db/queries_highlights.go b/backend/internal/db/queries_highlights.go new file mode 100644 index 0000000..354a425 --- /dev/null +++ b/backend/internal/db/queries_highlights.go @@ -0,0 +1,201 @@ +package db + +import ( + "time" +) + +func (db *DB) CreateHighlight(h *Highlight) error { + _, err := db.Exec(db.Rebind(` + INSERT INTO highlights (uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(uri) DO UPDATE SET + target_title = excluded.target_title, + selector_json = excluded.selector_json, + color = excluded.color, + tags_json = excluded.tags_json, + indexed_at = excluded.indexed_at, + cid = excluded.cid + `), h.URI, h.AuthorDID, h.TargetSource, h.TargetHash, h.TargetTitle, h.SelectorJSON, h.Color, h.TagsJSON, h.CreatedAt, h.IndexedAt, h.CID) + return err +} + +func (db *DB) GetHighlightByURI(uri string) (*Highlight, error) { + var h Highlight + err := db.QueryRow(db.Rebind(` + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid + FROM highlights + WHERE uri = ? + `), uri).Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID) + if err != nil { + return nil, err + } + return &h, nil +} + +func (db *DB) GetRecentHighlights(limit, offset int) ([]Highlight, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid + FROM highlights + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var highlights []Highlight + for rows.Next() { + var h Highlight + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { + return nil, err + } + highlights = append(highlights, h) + } + return highlights, nil +} + +func (db *DB) GetHighlightsByTag(tag string, limit, offset int) ([]Highlight, error) { + pattern := "%\"" + tag + "\"%" + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid + FROM highlights + WHERE tags_json LIKE ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), pattern, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var highlights []Highlight + for rows.Next() { + var h Highlight + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { + return nil, err + } + highlights = append(highlights, h) + } + return highlights, nil +} + +func (db *DB) GetHighlightsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Highlight, error) { + pattern := "%\"" + tag + "\"%" + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid + FROM highlights + WHERE author_did = ? AND tags_json LIKE ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), authorDID, pattern, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var highlights []Highlight + for rows.Next() { + var h Highlight + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { + return nil, err + } + highlights = append(highlights, h) + } + return highlights, nil +} + +func (db *DB) GetHighlightsByTargetHash(targetHash string, limit, offset int) ([]Highlight, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid + FROM highlights + WHERE target_hash = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), targetHash, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var highlights []Highlight + for rows.Next() { + var h Highlight + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { + return nil, err + } + highlights = append(highlights, h) + } + return highlights, nil +} + +func (db *DB) GetHighlightsByAuthor(authorDID string, limit, offset int) ([]Highlight, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid + FROM highlights + WHERE author_did = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), authorDID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var highlights []Highlight + for rows.Next() { + var h Highlight + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { + return nil, err + } + highlights = append(highlights, h) + } + return highlights, nil +} + +func (db *DB) DeleteHighlight(uri string) error { + _, err := db.Exec(db.Rebind(`DELETE FROM highlights WHERE uri = ?`), uri) + return err +} + +func (db *DB) UpdateHighlight(uri, color, tagsJSON, cid string) error { + _, err := db.Exec(db.Rebind(` + UPDATE highlights + SET color = ?, tags_json = ?, cid = ?, indexed_at = ? + WHERE uri = ? + `), color, tagsJSON, cid, time.Now(), uri) + return err +} + +func (db *DB) GetHighlightsByURIs(uris []string) ([]Highlight, error) { + if len(uris) == 0 { + return []Highlight{}, nil + } + + query := db.Rebind(` + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid + FROM highlights + WHERE uri IN (` + buildPlaceholders(len(uris)) + `) + `) + + args := make([]interface{}, len(uris)) + for i, uri := range uris { + args[i] = uri + } + + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var highlights []Highlight + for rows.Next() { + var h Highlight + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { + return nil, err + } + highlights = append(highlights, h) + } + return highlights, nil +} diff --git a/backend/internal/db/queries_history.go b/backend/internal/db/queries_history.go new file mode 100644 index 0000000..a73f160 --- /dev/null +++ b/backend/internal/db/queries_history.go @@ -0,0 +1,36 @@ +package db + +import ( + "time" +) + +func (db *DB) SaveEditHistory(uri, recordType, previousContent string, previousCID *string) error { + _, err := db.Exec(db.Rebind(` + INSERT INTO edit_history (uri, record_type, previous_content, previous_cid, edited_at) + VALUES (?, ?, ?, ?, ?) + `), uri, recordType, previousContent, previousCID, time.Now()) + return err +} + +func (db *DB) GetEditHistory(uri string) ([]EditHistory, error) { + rows, err := db.Query(db.Rebind(` + SELECT id, uri, record_type, previous_content, previous_cid, edited_at + FROM edit_history + WHERE uri = ? + ORDER BY edited_at DESC + `), uri) + if err != nil { + return nil, err + } + defer rows.Close() + + var history []EditHistory + for rows.Next() { + var h EditHistory + if err := rows.Scan(&h.ID, &h.URI, &h.RecordType, &h.PreviousContent, &h.PreviousCID, &h.EditedAt); err != nil { + return nil, err + } + history = append(history, h) + } + return history, nil +} diff --git a/backend/internal/db/queries_keys.go b/backend/internal/db/queries_keys.go new file mode 100644 index 0000000..a2c7b98 --- /dev/null +++ b/backend/internal/db/queries_keys.go @@ -0,0 +1,59 @@ +package db + +import ( + "time" +) + +func (db *DB) CreateAPIKey(key *APIKey) error { + _, err := db.Exec(db.Rebind(` + INSERT INTO api_keys (id, owner_did, name, key_hash, created_at) + VALUES (?, ?, ?, ?, ?) + `), key.ID, key.OwnerDID, key.Name, key.KeyHash, key.CreatedAt) + return err +} + +func (db *DB) GetAPIKeysByOwner(ownerDID string) ([]APIKey, error) { + rows, err := db.Query(db.Rebind(` + SELECT id, owner_did, name, key_hash, created_at, last_used_at + FROM api_keys + WHERE owner_did = ? + ORDER BY created_at DESC + `), ownerDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var keys []APIKey + for rows.Next() { + var k APIKey + if err := rows.Scan(&k.ID, &k.OwnerDID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsedAt); err != nil { + return nil, err + } + keys = append(keys, k) + } + return keys, nil +} + +func (db *DB) GetAPIKeyByHash(keyHash string) (*APIKey, error) { + var k APIKey + err := db.QueryRow(db.Rebind(` + SELECT id, owner_did, name, key_hash, created_at, last_used_at + FROM api_keys + WHERE key_hash = ? + `), keyHash).Scan(&k.ID, &k.OwnerDID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsedAt) + if err != nil { + return nil, err + } + return &k, nil +} + +func (db *DB) DeleteAPIKey(id, ownerDID string) error { + _, err := db.Exec(db.Rebind(`DELETE FROM api_keys WHERE id = ? AND owner_did = ?`), id, ownerDID) + return err +} + +func (db *DB) UpdateAPIKeyLastUsed(id string) error { + _, err := db.Exec(db.Rebind(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`), time.Now(), id) + return err +} diff --git a/backend/internal/db/queries_likes.go b/backend/internal/db/queries_likes.go new file mode 100644 index 0000000..c3cafcd --- /dev/null +++ b/backend/internal/db/queries_likes.go @@ -0,0 +1,105 @@ +package db + +func (db *DB) CreateLike(l *Like) error { + _, err := db.Exec(db.Rebind(` + INSERT INTO likes (uri, author_did, subject_uri, created_at, indexed_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(uri) DO NOTHING + `), l.URI, l.AuthorDID, l.SubjectURI, l.CreatedAt, l.IndexedAt) + return err +} + +func (db *DB) DeleteLike(uri string) error { + _, err := db.Exec(db.Rebind(`DELETE FROM likes WHERE uri = ?`), uri) + return err +} + +func (db *DB) GetLikeCount(subjectURI string) (int, error) { + var count int + err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM likes WHERE subject_uri = ?`), subjectURI).Scan(&count) + return count, err +} + +func (db *DB) GetLikeByUserAndSubject(userDID, subjectURI string) (*Like, error) { + var like Like + err := db.QueryRow(db.Rebind(` + SELECT uri, author_did, subject_uri, created_at, indexed_at + FROM likes + WHERE author_did = ? AND subject_uri = ? + `), userDID, subjectURI).Scan(&like.URI, &like.AuthorDID, &like.SubjectURI, &like.CreatedAt, &like.IndexedAt) + if err != nil { + return nil, err + } + return &like, nil +} + +func (db *DB) GetLikeCounts(subjectURIs []string) (map[string]int, error) { + if len(subjectURIs) == 0 { + return map[string]int{}, nil + } + + query := db.Rebind(` + SELECT subject_uri, COUNT(*) + FROM likes + WHERE subject_uri IN (` + buildPlaceholders(len(subjectURIs)) + `) + GROUP BY subject_uri + `) + + args := make([]interface{}, len(subjectURIs)) + for i, uri := range subjectURIs { + args[i] = uri + } + + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + counts := make(map[string]int) + for rows.Next() { + var uri string + var count int + if err := rows.Scan(&uri, &count); err != nil { + return nil, err + } + counts[uri] = count + } + + return counts, nil +} + +func (db *DB) GetViewerLikes(viewerDID string, subjectURIs []string) (map[string]bool, error) { + if len(subjectURIs) == 0 { + return map[string]bool{}, nil + } + + query := db.Rebind(` + SELECT subject_uri + FROM likes + WHERE author_did = ? AND subject_uri IN (` + buildPlaceholders(len(subjectURIs)) + `) + `) + + args := make([]interface{}, len(subjectURIs)+1) + args[0] = viewerDID + for i, uri := range subjectURIs { + args[i+1] = uri + } + + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + likes := make(map[string]bool) + for rows.Next() { + var uri string + if err := rows.Scan(&uri); err != nil { + return nil, err + } + likes[uri] = true + } + + return likes, nil +} diff --git a/backend/internal/db/queries_notifications.go b/backend/internal/db/queries_notifications.go new file mode 100644 index 0000000..1e72f42 --- /dev/null +++ b/backend/internal/db/queries_notifications.go @@ -0,0 +1,52 @@ +package db + +import ( + "time" +) + +func (db *DB) CreateNotification(n *Notification) error { + _, err := db.Exec(db.Rebind(` + INSERT INTO notifications (recipient_did, actor_did, type, subject_uri, created_at) + VALUES (?, ?, ?, ?, ?) + `), n.RecipientDID, n.ActorDID, n.Type, n.SubjectURI, n.CreatedAt) + return err +} + +func (db *DB) GetNotifications(recipientDID string, limit, offset int) ([]Notification, error) { + rows, err := db.Query(db.Rebind(` + SELECT id, recipient_did, actor_did, type, subject_uri, created_at, read_at + FROM notifications + WHERE recipient_did = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `), recipientDID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var notifications []Notification + for rows.Next() { + var n Notification + if err := rows.Scan(&n.ID, &n.RecipientDID, &n.ActorDID, &n.Type, &n.SubjectURI, &n.CreatedAt, &n.ReadAt); err != nil { + continue + } + notifications = append(notifications, n) + } + return notifications, nil +} + +func (db *DB) GetUnreadNotificationCount(recipientDID string) (int, error) { + var count int + err := db.QueryRow(db.Rebind(` + SELECT COUNT(*) FROM notifications WHERE recipient_did = ? AND read_at IS NULL + `), recipientDID).Scan(&count) + return count, err +} + +func (db *DB) MarkNotificationsRead(recipientDID string) error { + _, err := db.Exec(db.Rebind(` + UPDATE notifications SET read_at = ? WHERE recipient_did = ? AND read_at IS NULL + `), time.Now(), recipientDID) + return err +} diff --git a/backend/internal/db/queries_replies.go b/backend/internal/db/queries_replies.go new file mode 100644 index 0000000..d528bce --- /dev/null +++ b/backend/internal/db/queries_replies.go @@ -0,0 +1,176 @@ +package db + +func (db *DB) CreateReply(r *Reply) error { + _, err := db.Exec(db.Rebind(` + INSERT INTO replies (uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(uri) DO UPDATE SET + text = excluded.text, + format = excluded.format, + indexed_at = excluded.indexed_at, + cid = excluded.cid + `), r.URI, r.AuthorDID, r.ParentURI, r.RootURI, r.Text, r.Format, r.CreatedAt, r.IndexedAt, r.CID) + return err +} + +func (db *DB) GetRepliesByRoot(rootURI string) ([]Reply, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid + FROM replies + WHERE root_uri = ? + ORDER BY created_at ASC + `), rootURI) + if err != nil { + return nil, err + } + defer rows.Close() + + var replies []Reply + for rows.Next() { + var r Reply + if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { + return nil, err + } + replies = append(replies, r) + } + return replies, nil +} + +func (db *DB) GetReplyByURI(uri string) (*Reply, error) { + var r Reply + err := db.QueryRow(db.Rebind(` + SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid + FROM replies + WHERE uri = ? + `), uri).Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID) + if err != nil { + return nil, err + } + return &r, nil +} + +func (db *DB) DeleteReply(uri string) error { + _, err := db.Exec(db.Rebind(`DELETE FROM replies WHERE uri = ?`), uri) + return err +} + +func (db *DB) GetRepliesByAuthor(authorDID string) ([]Reply, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid + FROM replies + WHERE author_did = ? + ORDER BY created_at DESC + `), authorDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var replies []Reply + for rows.Next() { + var r Reply + if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { + return nil, err + } + replies = append(replies, r) + } + return replies, nil +} + +func (db *DB) GetOrphanedRepliesByAuthor(authorDID string) ([]Reply, error) { + rows, err := db.Query(db.Rebind(` + SELECT r.uri, r.author_did, r.parent_uri, r.root_uri, r.text, r.format, r.created_at, r.indexed_at, r.cid + FROM replies r + LEFT JOIN annotations a ON r.root_uri = a.uri + WHERE r.author_did = ? AND a.uri IS NULL + `), authorDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var replies []Reply + for rows.Next() { + var r Reply + if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { + return nil, err + } + replies = append(replies, r) + } + return replies, nil +} + +func (db *DB) GetReplyCount(rootURI string) (int, error) { + var count int + err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM replies WHERE root_uri = ?`), rootURI).Scan(&count) + return count, err +} + +func (db *DB) GetReplyCounts(rootURIs []string) (map[string]int, error) { + if len(rootURIs) == 0 { + return map[string]int{}, nil + } + + query := db.Rebind(` + SELECT root_uri, COUNT(*) + FROM replies + WHERE root_uri IN (` + buildPlaceholders(len(rootURIs)) + `) + GROUP BY root_uri + `) + + args := make([]interface{}, len(rootURIs)) + for i, uri := range rootURIs { + args[i] = uri + } + + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + counts := make(map[string]int) + for rows.Next() { + var uri string + var count int + if err := rows.Scan(&uri, &count); err != nil { + return nil, err + } + counts[uri] = count + } + + return counts, nil +} + +func (db *DB) GetRepliesByURIs(uris []string) ([]Reply, error) { + if len(uris) == 0 { + return []Reply{}, nil + } + + query := db.Rebind(` + SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid + FROM replies + WHERE uri IN (` + buildPlaceholders(len(uris)) + `) + `) + + args := make([]interface{}, len(uris)) + for i, uri := range uris { + args[i] = uri + } + + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var replies []Reply + for rows.Next() { + var r Reply + if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { + return nil, err + } + replies = append(replies, r) + } + return replies, nil +} diff --git a/backend/internal/db/queries_sessions.go b/backend/internal/db/queries_sessions.go new file mode 100644 index 0000000..ebed171 --- /dev/null +++ b/backend/internal/db/queries_sessions.go @@ -0,0 +1,32 @@ +package db + +import ( + "time" +) + +func (db *DB) SaveSession(id, did, handle, accessToken, refreshToken, dpopKey string, expiresAt time.Time) error { + _, err := db.Exec(db.Rebind(` + INSERT INTO sessions (id, did, handle, access_token, refresh_token, dpop_key, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + access_token = excluded.access_token, + refresh_token = excluded.refresh_token, + dpop_key = excluded.dpop_key, + expires_at = excluded.expires_at + `), id, did, handle, accessToken, refreshToken, dpopKey, time.Now(), expiresAt) + return err +} + +func (db *DB) GetSession(id string) (did, handle, accessToken, refreshToken, dpopKey string, err error) { + err = db.QueryRow(db.Rebind(` + SELECT did, handle, access_token, refresh_token, COALESCE(dpop_key, '') + FROM sessions + WHERE id = ? AND expires_at > ? + `), id, time.Now()).Scan(&did, &handle, &accessToken, &refreshToken, &dpopKey) + return +} + +func (db *DB) DeleteSession(id string) error { + _, err := db.Exec(db.Rebind(`DELETE FROM sessions WHERE id = ?`), id) + return err +} -- 2.51.2