package userdb import ( "database/sql" "fmt" "github.com/Silo-Server/silo-server/internal/userstore" ) // Collection is an alias for the canonical type in userstore. type Collection = userstore.Collection // CollectionItem is an alias for the canonical type in userstore. type CollectionItem = userstore.CollectionItem // CreateCollection creates a new personal collection with a generated UUID. func CreateCollection(db *sql.DB, input userstore.CreateCollectionInput) (*Collection, error) { id := generateUUID() now := nowUTC() if input.CollectionType == "" { input.CollectionType = "manual" } if input.QueryDefinition == "" { input.QueryDefinition = "{}" } if input.SortConfig == "" { input.SortConfig = "{}" } allowedProfiles := normalizeAllowedProfiles(input.CreatorProfileID, input.AllowedProfileIDs, input.IsShared) tx, err := db.Begin() if err != nil { return nil, err } defer tx.Rollback() _, err = tx.Exec( `INSERT INTO personal_collections ( id, profile_id, creator_profile_id, name, collection_type, is_shared, query_definition, sort_config, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, input.CreatorProfileID, input.CreatorProfileID, input.Name, input.CollectionType, input.IsShared, input.QueryDefinition, input.SortConfig, now, now, ) if err != nil { return nil, err } for _, profileID := range allowedProfiles { if _, err := tx.Exec( `INSERT INTO personal_collection_profiles (collection_id, profile_id) VALUES (?, ?)`, id, profileID, ); err != nil { return nil, err } } if err := tx.Commit(); err != nil { return nil, err } return &Collection{ ID: id, ProfileID: input.CreatorProfileID, CreatorProfileID: input.CreatorProfileID, Name: input.Name, CollectionType: input.CollectionType, IsShared: input.IsShared, AllowedProfileIDs: allowedProfiles, QueryDefinition: input.QueryDefinition, SortConfig: input.SortConfig, CreatedAt: now, UpdatedAt: now, }, nil } // GetCollection retrieves a collection by its ID. func GetCollection(db *sql.DB, id string) (*Collection, error) { var c Collection var isShared bool err := db.QueryRow( `SELECT id, profile_id, creator_profile_id, name, collection_type, is_shared, query_definition, sort_config, created_at, updated_at FROM personal_collections WHERE id = ?`, id, ).Scan(&c.ID, &c.ProfileID, &c.CreatorProfileID, &c.Name, &c.CollectionType, &isShared, &c.QueryDefinition, &c.SortConfig, &c.CreatedAt, &c.UpdatedAt) if err != nil { return nil, err } c.IsShared = isShared c.AllowedProfileIDs, err = listCollectionProfiles(db, id) if err != nil { return nil, err } return &c, nil } // ListCollections returns all collections for a given profile, ordered by creation date. func ListCollections(db *sql.DB, profileID string) ([]Collection, error) { rows, err := db.Query( `SELECT pc.id, pc.profile_id, pc.creator_profile_id, pc.name, pc.collection_type, pc.is_shared, pc.query_definition, pc.sort_config, pc.created_at, pc.updated_at FROM personal_collections pc JOIN personal_collection_profiles pcp ON pcp.collection_id = pc.id WHERE pcp.profile_id = ? ORDER BY pc.created_at ASC`, profileID, ) if err != nil { return nil, err } defer rows.Close() var collections []Collection for rows.Next() { var c Collection if err := rows.Scan(&c.ID, &c.ProfileID, &c.CreatorProfileID, &c.Name, &c.CollectionType, &c.IsShared, &c.QueryDefinition, &c.SortConfig, &c.CreatedAt, &c.UpdatedAt); err != nil { return nil, err } c.AllowedProfileIDs, err = listCollectionProfiles(db, c.ID) if err != nil { return nil, err } collections = append(collections, c) } return collections, rows.Err() } // UpdateCollection renames a collection and updates its updated_at timestamp. func UpdateCollection(db *sql.DB, input userstore.UpdateCollectionInput) error { var creatorProfileID string if err := db.QueryRow(`SELECT creator_profile_id FROM personal_collections WHERE id = ?`, input.ID).Scan(&creatorProfileID); err != nil { return err } if creatorProfileID != input.RequestProfileID { return fmt.Errorf("only the creator can update this collection") } tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() now := nowUTC() if input.Name != nil { if _, err := tx.Exec(`UPDATE personal_collections SET name = ?, updated_at = ? WHERE id = ?`, *input.Name, now, input.ID); err != nil { return err } } if input.IsShared != nil { if _, err := tx.Exec(`UPDATE personal_collections SET is_shared = ?, updated_at = ? WHERE id = ?`, *input.IsShared, now, input.ID); err != nil { return err } } if input.QueryDefinition != nil { if _, err := tx.Exec(`UPDATE personal_collections SET query_definition = ?, updated_at = ? WHERE id = ?`, *input.QueryDefinition, now, input.ID); err != nil { return err } } if input.SortConfig != nil { if _, err := tx.Exec(`UPDATE personal_collections SET sort_config = ?, updated_at = ? WHERE id = ?`, *input.SortConfig, now, input.ID); err != nil { return err } } if input.AllowedProfileIDs != nil || input.IsShared != nil { isShared := false if input.IsShared != nil { isShared = *input.IsShared } else { if err := tx.QueryRow(`SELECT is_shared FROM personal_collections WHERE id = ?`, input.ID).Scan(&isShared); err != nil { return err } } allowed := []string{} if input.AllowedProfileIDs != nil { allowed = *input.AllowedProfileIDs } else { allowed, err = listCollectionProfilesTx(tx, input.ID) if err != nil { return err } } allowed = normalizeAllowedProfiles(creatorProfileID, allowed, isShared) if _, err := tx.Exec(`DELETE FROM personal_collection_profiles WHERE collection_id = ?`, input.ID); err != nil { return err } for _, profileID := range allowed { if _, err := tx.Exec(`INSERT INTO personal_collection_profiles (collection_id, profile_id) VALUES (?, ?)`, input.ID, profileID); err != nil { return err } } } return tx.Commit() } // DeleteCollection removes a collection and all of its items. func DeleteCollection(db *sql.DB, id string) error { tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`DELETE FROM personal_collection_items WHERE collection_id = ?`, id); err != nil { return err } if _, err := tx.Exec(`DELETE FROM personal_collections WHERE id = ?`, id); err != nil { return err } return tx.Commit() } // AddCollectionItem adds a media item to a collection at the given position. // If the item already exists in the collection, the operation is a no-op. func AddCollectionItem(db *sql.DB, collectionID, mediaItemID string, position int) error { _, err := db.Exec( `INSERT OR IGNORE INTO personal_collection_items (collection_id, media_item_id, position, added_at) VALUES (?, ?, ?, ?)`, collectionID, mediaItemID, position, nowUTC(), ) return err } // RemoveCollectionItem removes a media item from a collection. func RemoveCollectionItem(db *sql.DB, collectionID, mediaItemID string) error { _, err := db.Exec( `DELETE FROM personal_collection_items WHERE collection_id = ? AND media_item_id = ?`, collectionID, mediaItemID, ) return err } // ListCollectionItems returns all items in a collection, ordered by position ascending. func ListCollectionItems(db *sql.DB, collectionID string) ([]CollectionItem, error) { rows, err := db.Query( `SELECT collection_id, media_item_id, position, added_at FROM personal_collection_items WHERE collection_id = ? ORDER BY position ASC`, collectionID, ) if err != nil { return nil, err } defer rows.Close() var items []CollectionItem for rows.Next() { var ci CollectionItem if err := rows.Scan(&ci.CollectionID, &ci.MediaItemID, &ci.Position, &ci.AddedAt); err != nil { return nil, err } items = append(items, ci) } return items, rows.Err() } func listCollectionProfiles(db *sql.DB, collectionID string) ([]string, error) { rows, err := db.Query( `SELECT profile_id FROM personal_collection_profiles WHERE collection_id = ? ORDER BY profile_id ASC`, collectionID, ) if err != nil { return nil, err } defer rows.Close() return scanCollectionProfiles(rows) } func listCollectionProfilesTx(tx *sql.Tx, collectionID string) ([]string, error) { rows, err := tx.Query( `SELECT profile_id FROM personal_collection_profiles WHERE collection_id = ? ORDER BY profile_id ASC`, collectionID, ) if err != nil { return nil, err } defer rows.Close() return scanCollectionProfiles(rows) } func scanCollectionProfiles(rows *sql.Rows) ([]string, error) { var profiles []string for rows.Next() { var profileID string if err := rows.Scan(&profileID); err != nil { return nil, err } profiles = append(profiles, profileID) } return profiles, rows.Err() } func normalizeAllowedProfiles(creatorProfileID string, allowedProfiles []string, isShared bool) []string { if !isShared { return []string{creatorProfileID} } seen := map[string]struct{}{creatorProfileID: {}} normalized := []string{creatorProfileID} for _, profileID := range allowedProfiles { if profileID == "" { continue } if _, ok := seen[profileID]; ok { continue } seen[profileID] = struct{}{} normalized = append(normalized, profileID) } return normalized }