Files
Spanglish/photo-api/internal/store/db.go
T
MichilisandCursor c9a600b6d6 Add photo galleries API and frontend for public and admin viewing.
Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 17:32:23 +00:00

123 lines
2.9 KiB
Go

// Package store owns all database access. Photo tables use the photos_
// prefix and are migrated by this service alone; the handful of reads
// against Drizzle-owned tables (users, events, tickets, payments) are
// confined to access.go.
package store
import (
"database/sql"
"fmt"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
)
const (
Postgres = "postgres"
SQLite = "sqlite"
// timeLayout is a fixed-width UTC format so SQLite text timestamps
// sort correctly; the backend stores ISO strings in SQLite the same way.
timeLayout = "2006-01-02T15:04:05.000Z"
)
type DB struct {
*sql.DB
Type string
}
func Open(cfg config.Config) (*DB, error) {
switch cfg.DBType {
case Postgres:
sqlDB, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("open postgres: %w", err)
}
sqlDB.SetMaxOpenConns(10)
return &DB{DB: sqlDB, Type: Postgres}, nil
case SQLite:
dsn := "file:" + strings.TrimPrefix(cfg.DatabaseURL, "file:") +
"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
sqlDB, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// One writer at a time keeps the shared file friendly to the backend.
sqlDB.SetMaxOpenConns(1)
return &DB{DB: sqlDB, Type: SQLite}, nil
default:
return nil, fmt.Errorf("unsupported DB_TYPE %q", cfg.DBType)
}
}
// Rebind converts ?-style placeholders to $n for Postgres. Queries in this
// package never contain literal question marks in strings.
func (db *DB) Rebind(query string) string {
if db.Type != Postgres {
return query
}
var b strings.Builder
n := 0
for _, r := range query {
if r == '?' {
n++
fmt.Fprintf(&b, "$%d", n)
} else {
b.WriteRune(r)
}
}
return b.String()
}
// TimeArg converts a time for binding: time.Time for Postgres (timestamptz
// columns), fixed-layout UTC text for SQLite (text columns).
func (db *DB) TimeArg(t time.Time) any {
if db.Type == Postgres {
return t.UTC()
}
return t.UTC().Format(timeLayout)
}
// asString normalizes scanned values across drivers.
func asString(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case []byte:
return string(x)
case time.Time:
return x.UTC().Format(timeLayout)
default:
return fmt.Sprintf("%v", x)
}
}
// asTime parses a scanned timestamp from either driver; zero time on failure.
func asTime(v any) time.Time {
switch x := v.(type) {
case time.Time:
return x.UTC()
case string:
return parseTime(x)
case []byte:
return parseTime(string(x))
default:
return time.Time{}
}
}
func parseTime(s string) time.Time {
for _, layout := range []string{timeLayout, time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05"} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC()
}
}
return time.Time{}
}