Add the store surface for summarization mode: - SetAutoSummarize / GetAutoSummarize — per-user auto/manual toggle; an absent user reads as manual (the safe default). - RequestSummarize — queue one video (summarize_requested=TRUE); ErrNotFound when the video is absent or not owned (RLS hides another user's row). - RequestedVideoIDs / ClearSummarizeRequested — the run-loop side: load the queued set per pass (mirrors SeenVideoIDs), clear after summarizing. - ListVideos / GetVideoRow — drive from the videos table LEFT JOIN summaries so discovered-but-unsummarized videos appear with empty summary fields. SummaryRow gains additive Summarized + SummarizeRequested fields; the summary-only reads are untouched. RLS proof: rls_test.go gains a cross-user "queue B's video" write asserting it touches zero rows (store-level scoping can't prove this — the test pool is a superuser that bypasses RLS, same caveat documented there). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
113 lines
4.0 KiB
Go
113 lines
4.0 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// SetAutoSummarize sets the user's auto/manual summarization mode. TRUE =
|
|
// automatic (every new video is summarized by `tapir run`); FALSE = manual (the
|
|
// user queues videos individually). Per-user, not global (ADR-012). Scoped via
|
|
// withUser, so RLS confines the UPDATE to the calling user's own row.
|
|
func (s *Store) SetAutoSummarize(ctx context.Context, userID string, enabled bool) error {
|
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
|
// Ensure the row exists (FK/identity target) before the UPDATE — mirrors
|
|
// the Deliver/UpsertVideo paths, so toggling mode works even before the
|
|
// first summary lands.
|
|
if _, err := tx.Exec(ctx,
|
|
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
|
|
userID); err != nil {
|
|
return fmt.Errorf("store: upsert user: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx,
|
|
`UPDATE users SET auto_summarize = $1 WHERE id = $2`, enabled, userID); err != nil {
|
|
return fmt.Errorf("store: set auto summarize: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// GetAutoSummarize reports the user's summarization mode (TRUE = automatic). An
|
|
// absent user row reads as FALSE (manual), the safe default. Scoped via withUser.
|
|
func (s *Store) GetAutoSummarize(ctx context.Context, userID string) (bool, error) {
|
|
var enabled bool
|
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
|
err := tx.QueryRow(ctx,
|
|
`SELECT auto_summarize FROM users WHERE id = $1`, userID).Scan(&enabled)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
enabled = false
|
|
return nil
|
|
}
|
|
return err
|
|
}); err != nil {
|
|
return false, fmt.Errorf("store: get auto summarize: %w", err)
|
|
}
|
|
return enabled, nil
|
|
}
|
|
|
|
// RequestSummarize queues a single video for manual summarization by setting its
|
|
// summarize_requested flag. The next `tapir run` picks it up and clears the flag.
|
|
// Returns ErrNotFound when the video does not exist or is not owned by the user
|
|
// (RLS hides another user's row, so the UPDATE matches zero rows). Scoped via
|
|
// withUser.
|
|
func (s *Store) RequestSummarize(ctx context.Context, userID, videoID string) error {
|
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
|
ct, err := tx.Exec(ctx,
|
|
`UPDATE videos SET summarize_requested = TRUE WHERE id = $1`, videoID)
|
|
if err != nil {
|
|
return fmt.Errorf("store: request summarize: %w", err)
|
|
}
|
|
if ct.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// RequestedVideoIDs returns the set of the user's video ids currently flagged for
|
|
// manual summarization. The run loop loads it once per pass (mirroring
|
|
// SeenVideoIDs) to decide which discovered videos to process in manual mode.
|
|
// Scoped by user_id.
|
|
func (s *Store) RequestedVideoIDs(ctx context.Context, userID string) (map[string]bool, error) {
|
|
requested := make(map[string]bool)
|
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
|
rows, err := tx.Query(ctx,
|
|
`SELECT id FROM videos WHERE user_id = $1 AND summarize_requested = TRUE`, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("store: requested video ids: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return fmt.Errorf("store: scan requested id: %w", err)
|
|
}
|
|
requested[id] = true
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("store: iterate requested ids: %w", err)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return requested, nil
|
|
}
|
|
|
|
// ClearSummarizeRequested resets a video's manual queue flag, called by the run
|
|
// loop after a queued video is successfully summarized so it is not re-processed
|
|
// and the list view drops the "Queued" chip. Scoped via withUser.
|
|
func (s *Store) ClearSummarizeRequested(ctx context.Context, userID, videoID string) error {
|
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
|
if _, err := tx.Exec(ctx,
|
|
`UPDATE videos SET summarize_requested = FALSE WHERE id = $1`, videoID); err != nil {
|
|
return fmt.Errorf("store: clear summarize requested: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|