// SPDX-License-Identifier: AGPL-3.0-or-later package store import ( "database/sql" "errors" "fmt" "strings" "time" "kode.naiv.no/olemd/favoritter/internal/model" ) var ErrFaveNotFound = errors.New("fave not found") type FaveStore struct { db *sql.DB } func NewFaveStore(db *sql.DB) *FaveStore { return &FaveStore{db: db} } // Create inserts a new fave and returns it with its ID populated. func (s *FaveStore) Create(userID int64, description, url, imagePath, notes, privacy string) (*model.Fave, error) { result, err := s.db.Exec( `INSERT INTO faves (user_id, description, url, image_path, notes, privacy) VALUES (?, ?, ?, ?, ?, ?)`, userID, description, url, imagePath, notes, privacy, ) if err != nil { return nil, fmt.Errorf("insert fave: %w", err) } id, _ := result.LastInsertId() return s.GetByID(id) } // GetByID returns a fave by its ID, including joined user info. func (s *FaveStore) GetByID(id int64) (*model.Fave, error) { f := &model.Fave{} var createdAt, updatedAt string err := s.db.QueryRow( `SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.notes, f.privacy, f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username) FROM faves f JOIN users u ON u.id = f.user_id WHERE f.id = ?`, id, ).Scan( &f.ID, &f.UserID, &f.Description, &f.URL, &f.ImagePath, &f.Notes, &f.Privacy, &createdAt, &updatedAt, &f.Username, &f.DisplayName, ) if errors.Is(err, sql.ErrNoRows) { return nil, ErrFaveNotFound } if err != nil { return nil, fmt.Errorf("query fave: %w", err) } f.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) f.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) return f, nil } // Update modifies an existing fave's fields. func (s *FaveStore) Update(id int64, description, url, imagePath, notes, privacy string) error { _, err := s.db.Exec( `UPDATE faves SET description = ?, url = ?, image_path = ?, notes = ?, privacy = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?`, description, url, imagePath, notes, privacy, id, ) return err } // Delete removes a fave by its ID. The cascade will clean up fave_tags. func (s *FaveStore) Delete(id int64) error { result, err := s.db.Exec("DELETE FROM faves WHERE id = ?", id) if err != nil { return err } n, _ := result.RowsAffected() if n == 0 { return ErrFaveNotFound } return nil } // ListByUser returns all faves for a user (both public and private), // ordered by newest first, with pagination. func (s *FaveStore) ListByUser(userID int64, limit, offset int) ([]*model.Fave, int, error) { var total int err := s.db.QueryRow("SELECT COUNT(*) FROM faves WHERE user_id = ?", userID).Scan(&total) if err != nil { return nil, 0, err } rows, err := s.db.Query( `SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.notes, f.privacy, f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username) FROM faves f JOIN users u ON u.id = f.user_id WHERE f.user_id = ? ORDER BY f.created_at DESC LIMIT ? OFFSET ?`, userID, limit, offset, ) if err != nil { return nil, 0, err } defer rows.Close() faves, err := s.scanFaves(rows) return faves, total, err } // ListPublicByUser returns only public faves for a user, with pagination. func (s *FaveStore) ListPublicByUser(userID int64, limit, offset int) ([]*model.Fave, int, error) { var total int err := s.db.QueryRow( "SELECT COUNT(*) FROM faves WHERE user_id = ? AND privacy = 'public'", userID, ).Scan(&total) if err != nil { return nil, 0, err } rows, err := s.db.Query( `SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.notes, f.privacy, f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username) FROM faves f JOIN users u ON u.id = f.user_id WHERE f.user_id = ? AND f.privacy = 'public' ORDER BY f.created_at DESC LIMIT ? OFFSET ?`, userID, limit, offset, ) if err != nil { return nil, 0, err } defer rows.Close() faves, err := s.scanFaves(rows) return faves, total, err } // ListPublic returns all public faves across all users, with pagination. func (s *FaveStore) ListPublic(limit, offset int) ([]*model.Fave, int, error) { var total int err := s.db.QueryRow("SELECT COUNT(*) FROM faves WHERE privacy = 'public'").Scan(&total) if err != nil { return nil, 0, err } rows, err := s.db.Query( `SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.notes, f.privacy, f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username) FROM faves f JOIN users u ON u.id = f.user_id WHERE f.privacy = 'public' ORDER BY f.created_at DESC LIMIT ? OFFSET ?`, limit, offset, ) if err != nil { return nil, 0, err } defer rows.Close() faves, err := s.scanFaves(rows) return faves, total, err } // ListByTag returns all public faves with a given tag, with pagination. func (s *FaveStore) ListByTag(tagName string, limit, offset int) ([]*model.Fave, int, error) { var total int err := s.db.QueryRow( `SELECT COUNT(*) FROM faves f JOIN fave_tags ft ON ft.fave_id = f.id JOIN tags t ON t.id = ft.tag_id WHERE t.name = ? AND f.privacy = 'public'`, tagName, ).Scan(&total) if err != nil { return nil, 0, err } rows, err := s.db.Query( `SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.notes, f.privacy, f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username) FROM faves f JOIN users u ON u.id = f.user_id JOIN fave_tags ft ON ft.fave_id = f.id JOIN tags t ON t.id = ft.tag_id WHERE t.name = ? AND f.privacy = 'public' ORDER BY f.created_at DESC LIMIT ? OFFSET ?`, tagName, limit, offset, ) if err != nil { return nil, 0, err } defer rows.Close() faves, err := s.scanFaves(rows) return faves, total, err } // Count returns the total number of faves. func (s *FaveStore) Count() (int, error) { var n int err := s.db.QueryRow("SELECT COUNT(*) FROM faves").Scan(&n) return n, err } // LoadTags populates the Tags field on each fave. func (s *FaveStore) LoadTags(faves []*model.Fave) error { if len(faves) == 0 { return nil } // Build a map for fast lookup. faveMap := make(map[int64]*model.Fave, len(faves)) ids := make([]any, len(faves)) placeholders := make([]string, len(faves)) for i, f := range faves { faveMap[f.ID] = f ids[i] = f.ID placeholders[i] = "?" } query := fmt.Sprintf( `SELECT ft.fave_id, t.id, t.name FROM fave_tags ft JOIN tags t ON t.id = ft.tag_id WHERE ft.fave_id IN (%s) ORDER BY t.name COLLATE NOCASE`, strings.Join(placeholders, ","), ) rows, err := s.db.Query(query, ids...) if err != nil { return fmt.Errorf("load tags: %w", err) } defer rows.Close() for rows.Next() { var faveID int64 var tag model.Tag if err := rows.Scan(&faveID, &tag.ID, &tag.Name); err != nil { return fmt.Errorf("scan tag: %w", err) } if f, ok := faveMap[faveID]; ok { f.Tags = append(f.Tags, tag) } } return rows.Err() } func (s *FaveStore) scanFaves(rows *sql.Rows) ([]*model.Fave, error) { var faves []*model.Fave for rows.Next() { f := &model.Fave{} var createdAt, updatedAt string err := rows.Scan( &f.ID, &f.UserID, &f.Description, &f.URL, &f.ImagePath, &f.Notes, &f.Privacy, &createdAt, &updatedAt, &f.Username, &f.DisplayName, ) if err != nil { return nil, fmt.Errorf("scan fave: %w", err) } f.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) f.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) faves = append(faves, f) } return faves, rows.Err() }