package store // Every query against Drizzle-owned tables (users, events, tickets, // payments) lives in this file so the read-coupling surface stays small // and auditable. Column semantics follow backend/src/db/schema.ts. import ( "context" "database/sql" "errors" "fmt" "time" ) type SessionUser struct { ID string Email string Role string AccountStatus string Banned bool ExpiresAt time.Time } // GetSessionUser resolves a Better Auth session token (from the // spanglish.session_token cookie) to its user. Sessions live in the shared // auth_sessions table, written by the backend; the row's presence is the // authoritative validity check, so revocations apply here instantly. func (db *DB) GetSessionUser(ctx context.Context, token string) (SessionUser, error) { row := db.QueryRowContext(ctx, db.Rebind(` SELECT u.id, u.email, u.role, u.account_status, u.banned, s.expires_at FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?`), token) var id, email, role, status, banned, expires any if err := row.Scan(&id, &email, &role, &status, &banned, &expires); err != nil { if errors.Is(err, sql.ErrNoRows) { return SessionUser{}, ErrNotFound } return SessionUser{}, err } return SessionUser{ ID: asString(id), Email: asString(email), Role: asString(role), AccountStatus: asString(status), Banned: asBool(banned), ExpiresAt: asTimeOrMillis(expires), }, nil } // asBool normalizes booleans across drivers (pg BOOLEAN, sqlite INTEGER 0/1). func asBool(v any) bool { switch x := v.(type) { case bool: return x case int64, int, int32, float64: return asInt(v) != 0 case []byte: return len(x) > 0 && x[0] != '0' && x[0] != 'f' && x[0] != 'F' default: return false } } // asTimeOrMillis parses a timestamp that is a pg TIMESTAMP on postgres but an // epoch-milliseconds INTEGER on sqlite (Drizzle timestamp_ms mode). func asTimeOrMillis(v any) time.Time { switch v.(type) { case int64, int, int32, float64: return time.UnixMilli(asInt(v)).UTC() default: return asTime(v) } } // HasPaidTicket implements the review decision: a confirmed/checked-in // ticket whose payment is 'paid', or any confirmed/checked-in ticket when // the event is free (price = 0). func (db *DB) HasPaidTicket(ctx context.Context, userID, eventID string) (bool, error) { var v any err := db.QueryRowContext(ctx, db.Rebind(` SELECT 1 FROM tickets t JOIN events e ON e.id = t.event_id LEFT JOIN payments p ON p.ticket_id = t.id WHERE t.user_id = ? AND t.event_id = ? AND t.status IN ('confirmed','checked_in') AND (p.status = 'paid' OR e.price = 0) LIMIT 1`), userID, eventID).Scan(&v) if errors.Is(err, sql.ErrNoRows) { return false, nil } return err == nil, err } type EventSummary struct { ID string Slug string Title string TitleEs string StartDatetime time.Time Status string } func (db *DB) GetEventSummary(ctx context.Context, eventID string) (EventSummary, error) { return db.getEventWhere(ctx, "id = ?", eventID) } // GetEventSummaryBySlug resolves the /events/{slug}/gallery public route. func (db *DB) GetEventSummaryBySlug(ctx context.Context, slug string) (EventSummary, error) { return db.getEventWhere(ctx, "slug = ?", slug) } func (db *DB) getEventWhere(ctx context.Context, where string, arg any) (EventSummary, error) { row := db.QueryRowContext(ctx, db.Rebind( "SELECT id, slug, title, title_es, start_datetime, status FROM events WHERE "+where), arg) var id, slug, title, titleEs, start, status any if err := row.Scan(&id, &slug, &title, &titleEs, &start, &status); err != nil { if errors.Is(err, sql.ErrNoRows) { return EventSummary{}, ErrNotFound } return EventSummary{}, err } return EventSummary{ ID: asString(id), Slug: asString(slug), Title: asString(title), TitleEs: asString(titleEs), StartDatetime: asTime(start), Status: asString(status), }, nil } // CheckMainSchema fails fast at startup if the columns this service reads // from Drizzle-owned tables have been renamed or dropped. func (db *DB) CheckMainSchema(ctx context.Context) error { checks := []string{ "SELECT id, email, role, account_status, banned FROM users WHERE 1 = 0", "SELECT id, user_id, token, expires_at FROM auth_sessions WHERE 1 = 0", "SELECT id, slug, title, title_es, start_datetime, status, price FROM events WHERE 1 = 0", "SELECT id, user_id, event_id, status FROM tickets WHERE 1 = 0", "SELECT id, ticket_id, status FROM payments WHERE 1 = 0", } for _, q := range checks { if _, err := db.ExecContext(ctx, q); err != nil { return fmt.Errorf("main app schema check failed (%s): %w", q, err) } } return nil }