package galleries import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "github.com/google/uuid" ) type Repository struct { db *sql.DB } func NewRepository(db *sql.DB) *Repository { return &Repository{db: db} } var ErrNotFound = errors.New("gallery not found") func (r *Repository) Create(ctx context.Context, userID uuid.UUID, slug, title, clientName, description string) (GalleryRecord, error) { id := uuid.New() _, err := r.db.ExecContext(ctx, ` INSERT INTO galleries (id, user_id, slug, title, client_name, description, status, theme_config, branding_config) VALUES ($1, $2, $3, $4, $5, $6, 'draft', '{}', '{}') `, id, userID, slug, title, clientName, description) if err != nil { if strings.Contains(strings.ToLower(err.Error()), "unique") { return GalleryRecord{}, fmt.Errorf("gallery slug already exists: %w", err) } return GalleryRecord{}, fmt.Errorf("create gallery: %w", err) } return r.GetForUser(ctx, userID, id) } func (r *Repository) ListForUser(ctx context.Context, userID uuid.UUID) ([]Summary, error) { rows, err := r.db.QueryContext(ctx, ` SELECT g.id, g.slug, g.title, g.client_name, g.description, g.status, g.downloads_enabled, g.favorites_enabled, g.download_all_enabled, g.watermark_enabled, g.expires_at, g.cover_media_id, g.theme_config, g.branding_config, g.created_at, g.published_at, COUNT(CASE WHEN m.mime_type LIKE 'image/%' THEN 1 END), COUNT(CASE WHEN m.mime_type LIKE 'video/%' THEN 1 END), COALESCE(SUM(m.file_size), 0) FROM galleries g LEFT JOIN media m ON m.gallery_id = g.id WHERE g.user_id = $1 AND g.status <> 'archived' GROUP BY g.id ORDER BY g.created_at DESC `, userID) if err != nil { return nil, fmt.Errorf("list galleries: %w", err) } defer rows.Close() result := make([]Summary, 0) for rows.Next() { var ( item Summary expiresAt, coverMediaID, createdAt sql.NullString publishedAt sql.NullString themeConfig, brandingConfig []byte ) if err := rows.Scan( &item.ID, &item.Slug, &item.Title, &item.ClientName, &item.Description, &item.Status, &item.DownloadsEnabled, &item.FavoritesEnabled, &item.DownloadAllEnabled, &item.WatermarkEnabled, &expiresAt, &coverMediaID, &themeConfig, &brandingConfig, &createdAt, &publishedAt, &item.PhotoCount, &item.VideoCount, &item.TotalBytes, ); err != nil { return nil, fmt.Errorf("scan gallery summary: %w", err) } item.ExpiresAt = expiresAt.String item.CoverMediaID = coverMediaID.String item.ThemeConfig = nonEmptyJSON(themeConfig) item.BrandingConfig = nonEmptyJSON(brandingConfig) item.CreatedAt = createdAt.String item.PublishedAt = publishedAt.String result = append(result, item) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate galleries: %w", err) } return result, nil } func (r *Repository) GetForUser(ctx context.Context, userID, galleryID uuid.UUID) (GalleryRecord, error) { return r.get(ctx, ` SELECT id, user_id, slug, title, client_name, description, status, password_hash, downloads_enabled, favorites_enabled, download_all_enabled, watermark_enabled, expires_at, cover_media_id, theme_config, branding_config, created_at, updated_at, published_at FROM galleries WHERE id = $1 AND user_id = $2 `, galleryID, userID) } func (r *Repository) GetPublicBySlug(ctx context.Context, slug string) (GalleryRecord, error) { return r.get(ctx, ` SELECT id, user_id, slug, title, client_name, description, status, password_hash, downloads_enabled, favorites_enabled, download_all_enabled, watermark_enabled, expires_at, cover_media_id, theme_config, branding_config, created_at, updated_at, published_at FROM galleries WHERE slug = $1 AND status = 'published' `, slug) } func (r *Repository) Update(ctx context.Context, userID, galleryID uuid.UUID, input UpdateInput) (GalleryRecord, error) { var passwordValue any if input.ClearPassword { passwordValue = nil } else if input.PasswordHash != nil { passwordValue = *input.PasswordHash } else { current, err := r.GetForUser(ctx, userID, galleryID) if err != nil { return GalleryRecord{}, err } passwordValue = current.PasswordHash } var expiresValue any if input.ExpiresAt != nil && strings.TrimSpace(*input.ExpiresAt) != "" { expiresValue = *input.ExpiresAt } var coverValue any if input.CoverMediaID != nil && strings.TrimSpace(*input.CoverMediaID) != "" { coverID, err := uuid.Parse(strings.TrimSpace(*input.CoverMediaID)) if err != nil { return GalleryRecord{}, fmt.Errorf("parse cover media id: %w", err) } coverValue = coverID } _, err := r.db.ExecContext(ctx, ` UPDATE galleries SET title = $1, client_name = $2, description = $3, password_hash = $4, downloads_enabled = $5, favorites_enabled = $6, download_all_enabled = $7, watermark_enabled = $8, expires_at = $9, cover_media_id = $10, theme_config = $11, branding_config = $12, updated_at = CURRENT_TIMESTAMP WHERE id = $13 AND user_id = $14 `, input.Title, input.ClientName, input.Description, passwordValue, input.DownloadsEnabled, input.FavoritesEnabled, input.DownloadAllEnabled, input.WatermarkEnabled, expiresValue, coverValue, jsonValue(input.ThemeConfig), jsonValue(input.BrandingConfig), galleryID, userID) if err != nil { return GalleryRecord{}, fmt.Errorf("update gallery: %w", err) } return r.GetForUser(ctx, userID, galleryID) } func (r *Repository) SetStatus(ctx context.Context, userID, galleryID uuid.UUID, status string) (GalleryRecord, error) { var query string if status == StatusPublished { query = `UPDATE galleries SET status = $1, published_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_id = $3` } else { query = `UPDATE galleries SET status = $1, published_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_id = $3` } _, err := r.db.ExecContext(ctx, query, status, galleryID, userID) if err != nil { return GalleryRecord{}, fmt.Errorf("set gallery status: %w", err) } return r.GetForUser(ctx, userID, galleryID) } func (r *Repository) Delete(ctx context.Context, userID, galleryID uuid.UUID) error { result, err := r.db.ExecContext(ctx, `DELETE FROM galleries WHERE id = $1 AND user_id = $2`, galleryID, userID) if err != nil { return fmt.Errorf("delete gallery: %w", err) } count, err := result.RowsAffected() if err != nil || count == 0 { return ErrNotFound } return nil } type rowScanner interface { Scan(...any) error } func (r *Repository) get(ctx context.Context, query string, args ...any) (GalleryRecord, error) { return scanGallery(r.db.QueryRowContext(ctx, query, args...)) } func scanGallery(row rowScanner) (GalleryRecord, error) { var ( gallery GalleryRecord passwordHash, expiresAt, coverMediaID sql.NullString themeConfig, brandingConfig []byte createdAt, updatedAt, publishedAt sql.NullString ) err := row.Scan( &gallery.ID, &gallery.UserID, &gallery.Slug, &gallery.Title, &gallery.ClientName, &gallery.Description, &gallery.Status, &passwordHash, &gallery.DownloadsEnabled, &gallery.FavoritesEnabled, &gallery.DownloadAllEnabled, &gallery.WatermarkEnabled, &expiresAt, &coverMediaID, &themeConfig, &brandingConfig, &createdAt, &updatedAt, &publishedAt, ) if errors.Is(err, sql.ErrNoRows) { return GalleryRecord{}, ErrNotFound } if err != nil { return GalleryRecord{}, fmt.Errorf("scan gallery: %w", err) } gallery.PasswordHash = passwordHash.String gallery.ExpiresAt = expiresAt.String gallery.CoverMediaID = coverMediaID.String gallery.ThemeConfig = nonEmptyJSON(themeConfig) gallery.BrandingConfig = nonEmptyJSON(brandingConfig) gallery.CreatedAt = createdAt.String gallery.UpdatedAt = updatedAt.String gallery.PublishedAt = publishedAt.String return gallery, nil } func nonEmptyJSON(value []byte) json.RawMessage { if len(value) == 0 { return json.RawMessage(`{}`) } return json.RawMessage(value) } func jsonValue(value json.RawMessage) string { if len(value) == 0 { return `{}` } return string(value) }