package storage import ( "database/sql" "fmt" "strings" ) // SetRead marks (read=true) or clears (read=false) the read flag for one story // and one signed-in user (keyed by OIDC subject). Read and bookmark state share // a row; clearing the last remaining flag removes the row. func SetRead(sub string, storyID int64, read bool) error { var ts any if read { ts = nowUnix() } _, err := Get().Exec(` INSERT INTO user_story_state (user_sub, story_id, read_at, bookmarked_at) VALUES (?, ?, ?, NULL) ON CONFLICT(user_sub, story_id) DO UPDATE SET read_at = excluded.read_at`, sub, storyID, ts) if err != nil { return fmt.Errorf("set read: %w", err) } return pruneEmptyState(sub, storyID) } // SetBookmark adds (on=true) or removes (on=false) a bookmark for one story and // one signed-in user. See SetRead for the shared-row semantics. func SetBookmark(sub string, storyID int64, on bool) error { var ts any if on { ts = nowUnix() } _, err := Get().Exec(` INSERT INTO user_story_state (user_sub, story_id, read_at, bookmarked_at) VALUES (?, ?, NULL, ?) ON CONFLICT(user_sub, story_id) DO UPDATE SET bookmarked_at = excluded.bookmarked_at`, sub, storyID, ts) if err != nil { return fmt.Errorf("set bookmark: %w", err) } return pruneEmptyState(sub, storyID) } // pruneEmptyState drops a row once neither flag is set, keeping the table to // only meaningful state. func pruneEmptyState(sub string, storyID int64) error { _, err := Get().Exec( `DELETE FROM user_story_state WHERE user_sub = ? AND story_id = ? AND read_at IS NULL AND bookmarked_at IS NULL`, sub, storyID) if err != nil { return fmt.Errorf("prune user state: %w", err) } return nil } // UserStoryState reports, for the given story ids, which are read and which are // bookmarked by this user. Both maps contain only ids whose flag is set, so a // missing key means false. An empty sub or id list returns empty maps. func UserStoryState(sub string, ids []int64) (read, bookmarked map[int64]bool, err error) { read = make(map[int64]bool) bookmarked = make(map[int64]bool) if sub == "" || len(ids) == 0 { return read, bookmarked, nil } q := `SELECT story_id, read_at, bookmarked_at FROM user_story_state WHERE user_sub = ? AND story_id IN (` + placeholders(len(ids)) + `)` args := make([]any, 0, len(ids)+1) args = append(args, sub) for _, id := range ids { args = append(args, id) } rows, err := Get().Query(q, args...) if err != nil { return nil, nil, fmt.Errorf("user story state: %w", err) } defer rows.Close() for rows.Next() { var id int64 var r, b sql.NullInt64 if err := rows.Scan(&id, &r, &b); err != nil { return nil, nil, err } if r.Valid { read[id] = true } if b.Valid { bookmarked[id] = true } } return read, bookmarked, rows.Err() } // ListBookmarks returns the user's bookmarked stories, most recently bookmarked // first, restricted to still-classified stories. func ListBookmarks(sub string, limit, offset int) ([]Story, error) { rows, err := Get().Query( `SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at, EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted FROM user_story_state u JOIN stories s ON s.id = u.story_id WHERE u.user_sub = ? AND u.bookmarked_at IS NOT NULL AND s.classified = 1 ORDER BY u.bookmarked_at DESC, u.story_id DESC LIMIT ? OFFSET ?`, sub, limit, offset) if err != nil { return nil, err } defer rows.Close() var out []Story for rows.Next() { var s Story if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil { return nil, err } out = append(out, s) } return out, rows.Err() } // CountBookmarks returns how many stories the user has bookmarked. func CountBookmarks(sub string) (int, error) { var n int err := Get().QueryRow( `SELECT COUNT(*) FROM user_story_state WHERE user_sub = ? AND bookmarked_at IS NOT NULL`, sub).Scan(&n) return n, err } // placeholders returns "?, ?, …" with n slots for an IN clause. func placeholders(n int) string { if n <= 0 { return "" } return strings.Repeat("?, ", n-1) + "?" }