diff --git a/package.json b/package.json index 0148123..92be7ea 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "lint": "eslint", "test": "vitest run", "test:watch": "vitest", - "check:rls": "node --env-file=.env.local scripts/check-rls.mjs" + "check:rls": "node --env-file=.env.local scripts/check-rls.mjs", + "gen:types": "node --env-file=.env.local scripts/gen-types.mjs" }, "dependencies": { "@supabase/ssr": "^0.12.7", diff --git a/scripts/gen-types.mjs b/scripts/gen-types.mjs new file mode 100644 index 0000000..bcb6a6e --- /dev/null +++ b/scripts/gen-types.mjs @@ -0,0 +1,140 @@ +// Generates src/lib/database.types.ts from the live database's API schema, in the same +// shape as `supabase gen types typescript` — without needing a Supabase CLI login. +// Run with: npm run gen:types (after every migration) +import { writeFileSync } from "node:fs"; + +const url = process.env.NEXT_PUBLIC_SUPABASE_URL; +const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; +if (!url || !serviceKey) { + console.error("Missing Supabase env vars. Fill in .env.local first."); + process.exit(1); +} + +// Foreign keys whose constraint name isn't Postgres' default `__fkey`. +const FK_NAME_OVERRIDES = { "events.current_game_id": "events_current_game_fk" }; + +// Columns with a DB default that the API schema doesn't report (it omits array defaults). +const EXTRA_DEFAULTS = new Set(["games.placement_points"]); + +const response = await fetch(`${url}/rest/v1/`, { + headers: { apikey: serviceKey, Authorization: `Bearer ${serviceKey}` }, +}); +if (!response.ok) { + console.error(`Could not read the schema: HTTP ${response.status}`); + process.exit(1); +} +const { definitions = {} } = await response.json(); +const tables = Object.keys(definitions).sort(); +if (tables.length === 0) { + console.error("No tables found. Has the migration been applied?"); + process.exit(1); +} + +const enums = {}; + +function tsType(column) { + if (column.enum && column.format?.startsWith("public.")) { + const name = column.format.slice("public.".length); + enums[name] = column.enum; + return `Database["public"]["Enums"]["${name}"]`; + } + if (column.type === "array") return `${tsType(column.items)}[]`; + if (column.format === "json" || column.format === "jsonb") return "Json"; + if (column.type === "integer" || column.type === "number") return "number"; + if (column.type === "boolean") return "boolean"; + return "string"; +} + +function tableBlock(table) { + const { properties, required = [] } = definitions[table]; + const columns = Object.keys(properties).sort(); + const row = []; + const insert = []; + const update = []; + const relationships = []; + + for (const name of columns) { + const column = properties[name]; + const nullable = !required.includes(name); + const type = tsType(column) + (nullable ? " | null" : ""); + const optionalOnInsert = + nullable || column.default !== undefined || EXTRA_DEFAULTS.has(`${table}.${name}`); + row.push(` ${name}: ${type};`); + insert.push(` ${name}${optionalOnInsert ? "?" : ""}: ${type};`); + update.push(` ${name}?: ${type};`); + + const fk = column.description?.match(//); + if (fk) { + const fkName = FK_NAME_OVERRIDES[`${table}.${name}`] ?? `${table}_${name}_fkey`; + relationships.push( + [ + " {", + ` foreignKeyName: "${fkName}";`, + ` columns: ["${name}"];`, + " isOneToOne: false;", + ` referencedRelation: "${fk[1]}";`, + ` referencedColumns: ["${fk[2]}"];`, + " },", + ].join("\n"), + ); + } + } + + return [ + ` ${table}: {`, + " Row: {", + ...row, + " };", + " Insert: {", + ...insert, + " };", + " Update: {", + ...update, + " };", + relationships.length ? ` Relationships: [\n${relationships.join("\n")}\n ];` : " Relationships: [];", + " };", + ].join("\n"); +} + +const tableBlocks = tables.map(tableBlock).join("\n"); +const enumBlock = Object.keys(enums) + .sort() + .map((name) => ` ${name}: ${enums[name].map((value) => `"${value}"`).join(" | ")};`) + .join("\n"); + +const output = `// GENERATED by scripts/gen-types.mjs from the live database — do not edit by hand. +// Regenerate with: npm run gen:types + +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; + +export type Database = { + public: { + Tables: { +${tableBlocks} + }; + Views: { + [_ in never]: never; + }; + Functions: { + [_ in never]: never; + }; + Enums: { +${enumBlock} + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +}; + +export type Tables = + Database["public"]["Tables"][T]["Row"]; +export type TablesInsert = + Database["public"]["Tables"][T]["Insert"]; +export type TablesUpdate = + Database["public"]["Tables"][T]["Update"]; +export type Enums = Database["public"]["Enums"][T]; +`; + +writeFileSync(new URL("../src/lib/database.types.ts", import.meta.url), output); +console.log(`Wrote src/lib/database.types.ts (${tables.length} tables, ${Object.keys(enums).length} enums).`); diff --git a/src/lib/database.types.ts b/src/lib/database.types.ts new file mode 100644 index 0000000..42b50cf --- /dev/null +++ b/src/lib/database.types.ts @@ -0,0 +1,398 @@ +// GENERATED by scripts/gen-types.mjs from the live database — do not edit by hand. +// Regenerate with: npm run gen:types + +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; + +export type Database = { + public: { + Tables: { + adjustments: { + Row: { + created_at: string; + event_id: string; + game_id: string | null; + id: string; + points: number; + reason: string | null; + team_id: string; + }; + Insert: { + created_at?: string; + event_id: string; + game_id?: string | null; + id?: string; + points: number; + reason?: string | null; + team_id: string; + }; + Update: { + created_at?: string; + event_id?: string; + game_id?: string | null; + id?: string; + points?: number; + reason?: string | null; + team_id?: string; + }; + Relationships: [ + { + foreignKeyName: "adjustments_event_id_fkey"; + columns: ["event_id"]; + isOneToOne: false; + referencedRelation: "events"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "adjustments_game_id_fkey"; + columns: ["game_id"]; + isOneToOne: false; + referencedRelation: "games"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "adjustments_team_id_fkey"; + columns: ["team_id"]; + isOneToOne: false; + referencedRelation: "teams"; + referencedColumns: ["id"]; + }, + ]; + }; + criteria: { + Row: { + description: string | null; + game_id: string; + id: string; + input_type: Database["public"]["Enums"]["criterion_input"]; + label: string; + max_value: number; + min_value: number; + sort_order: number; + step: number; + weight: number; + }; + Insert: { + description?: string | null; + game_id: string; + id?: string; + input_type?: Database["public"]["Enums"]["criterion_input"]; + label: string; + max_value?: number; + min_value?: number; + sort_order?: number; + step?: number; + weight?: number; + }; + Update: { + description?: string | null; + game_id?: string; + id?: string; + input_type?: Database["public"]["Enums"]["criterion_input"]; + label?: string; + max_value?: number; + min_value?: number; + sort_order?: number; + step?: number; + weight?: number; + }; + Relationships: [ + { + foreignKeyName: "criteria_game_id_fkey"; + columns: ["game_id"]; + isOneToOne: false; + referencedRelation: "games"; + referencedColumns: ["id"]; + }, + ]; + }; + events: { + Row: { + created_at: string; + current_game_id: string | null; + event_date: string | null; + frozen_snapshot: Json | null; + id: string; + name: string; + owner_id: string; + reveal_active: boolean; + reveal_count: number; + scoreboard_token: string; + scoreboard_visibility: Database["public"]["Enums"]["scoreboard_visibility"]; + updated_at: string; + }; + Insert: { + created_at?: string; + current_game_id?: string | null; + event_date?: string | null; + frozen_snapshot?: Json | null; + id?: string; + name: string; + owner_id: string; + reveal_active?: boolean; + reveal_count?: number; + scoreboard_token?: string; + scoreboard_visibility?: Database["public"]["Enums"]["scoreboard_visibility"]; + updated_at?: string; + }; + Update: { + created_at?: string; + current_game_id?: string | null; + event_date?: string | null; + frozen_snapshot?: Json | null; + id?: string; + name?: string; + owner_id?: string; + reveal_active?: boolean; + reveal_count?: number; + scoreboard_token?: string; + scoreboard_visibility?: Database["public"]["Enums"]["scoreboard_visibility"]; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: "events_current_game_fk"; + columns: ["current_game_id"]; + isOneToOne: false; + referencedRelation: "games"; + referencedColumns: ["id"]; + }, + ]; + }; + games: { + Row: { + admin_notes: string | null; + created_at: string; + description: string | null; + event_id: string; + id: string; + judge_notes: string | null; + judging_locked: boolean; + multiplier: number; + open_link_enabled: boolean; + open_token: string; + placement_points: number[]; + points_mode: Database["public"]["Enums"]["points_mode"]; + scoring_mode: Database["public"]["Enums"]["scoring_mode"]; + sort_order: number; + spotlight_team_id: string | null; + status: Database["public"]["Enums"]["game_status"]; + title: string; + updated_at: string; + }; + Insert: { + admin_notes?: string | null; + created_at?: string; + description?: string | null; + event_id: string; + id?: string; + judge_notes?: string | null; + judging_locked?: boolean; + multiplier?: number; + open_link_enabled?: boolean; + open_token?: string; + placement_points?: number[]; + points_mode?: Database["public"]["Enums"]["points_mode"]; + scoring_mode?: Database["public"]["Enums"]["scoring_mode"]; + sort_order?: number; + spotlight_team_id?: string | null; + status?: Database["public"]["Enums"]["game_status"]; + title: string; + updated_at?: string; + }; + Update: { + admin_notes?: string | null; + created_at?: string; + description?: string | null; + event_id?: string; + id?: string; + judge_notes?: string | null; + judging_locked?: boolean; + multiplier?: number; + open_link_enabled?: boolean; + open_token?: string; + placement_points?: number[]; + points_mode?: Database["public"]["Enums"]["points_mode"]; + scoring_mode?: Database["public"]["Enums"]["scoring_mode"]; + sort_order?: number; + spotlight_team_id?: string | null; + status?: Database["public"]["Enums"]["game_status"]; + title?: string; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: "games_event_id_fkey"; + columns: ["event_id"]; + isOneToOne: false; + referencedRelation: "events"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "games_spotlight_team_id_fkey"; + columns: ["spotlight_team_id"]; + isOneToOne: false; + referencedRelation: "teams"; + referencedColumns: ["id"]; + }, + ]; + }; + judges: { + Row: { + created_at: string; + game_id: string; + id: string; + name: string; + revoked: boolean; + source: Database["public"]["Enums"]["judge_source"]; + token: string; + }; + Insert: { + created_at?: string; + game_id: string; + id?: string; + name: string; + revoked?: boolean; + source?: Database["public"]["Enums"]["judge_source"]; + token?: string; + }; + Update: { + created_at?: string; + game_id?: string; + id?: string; + name?: string; + revoked?: boolean; + source?: Database["public"]["Enums"]["judge_source"]; + token?: string; + }; + Relationships: [ + { + foreignKeyName: "judges_game_id_fkey"; + columns: ["game_id"]; + isOneToOne: false; + referencedRelation: "games"; + referencedColumns: ["id"]; + }, + ]; + }; + scores: { + Row: { + criterion_id: string; + game_id: string; + id: string; + judge_id: string | null; + team_id: string; + updated_at: string; + value: number; + }; + Insert: { + criterion_id: string; + game_id: string; + id?: string; + judge_id?: string | null; + team_id: string; + updated_at?: string; + value: number; + }; + Update: { + criterion_id?: string; + game_id?: string; + id?: string; + judge_id?: string | null; + team_id?: string; + updated_at?: string; + value?: number; + }; + Relationships: [ + { + foreignKeyName: "scores_criterion_id_fkey"; + columns: ["criterion_id"]; + isOneToOne: false; + referencedRelation: "criteria"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "scores_game_id_fkey"; + columns: ["game_id"]; + isOneToOne: false; + referencedRelation: "games"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "scores_judge_id_fkey"; + columns: ["judge_id"]; + isOneToOne: false; + referencedRelation: "judges"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "scores_team_id_fkey"; + columns: ["team_id"]; + isOneToOne: false; + referencedRelation: "teams"; + referencedColumns: ["id"]; + }, + ]; + }; + teams: { + Row: { + color: string; + created_at: string; + event_id: string; + id: string; + name: string; + sort_order: number; + }; + Insert: { + color?: string; + created_at?: string; + event_id: string; + id?: string; + name: string; + sort_order?: number; + }; + Update: { + color?: string; + created_at?: string; + event_id?: string; + id?: string; + name?: string; + sort_order?: number; + }; + Relationships: [ + { + foreignKeyName: "teams_event_id_fkey"; + columns: ["event_id"]; + isOneToOne: false; + referencedRelation: "events"; + referencedColumns: ["id"]; + }, + ]; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + [_ in never]: never; + }; + Enums: { + criterion_input: "blocks" | "number"; + game_status: "upcoming" | "live" | "done"; + judge_source: "nominated" | "open_link"; + points_mode: "raw" | "placement"; + scoreboard_visibility: "visible" | "frozen" | "hidden"; + scoring_mode: "judged" | "direct"; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +}; + +export type Tables = + Database["public"]["Tables"][T]["Row"]; +export type TablesInsert = + Database["public"]["Tables"][T]["Insert"]; +export type TablesUpdate = + Database["public"]["Tables"][T]["Update"]; +export type Enums = Database["public"]["Enums"][T]; diff --git a/src/lib/supabase/admin.ts b/src/lib/supabase/admin.ts index 4c1711e..505d64a 100644 --- a/src/lib/supabase/admin.ts +++ b/src/lib/supabase/admin.ts @@ -2,6 +2,7 @@ import "server-only"; import { createClient } from "@supabase/supabase-js"; +import type { Database } from "@/lib/database.types"; import { serverEnv } from "@/lib/env.server"; /** @@ -9,7 +10,7 @@ import { serverEnv } from "@/lib/env.server"; * AFTER the scoreboard/judge token has been validated, and scope every query by that token. */ export function createAdminClient() { - return createClient(serverEnv.NEXT_PUBLIC_SUPABASE_URL, serverEnv.SUPABASE_SERVICE_ROLE_KEY, { + return createClient(serverEnv.NEXT_PUBLIC_SUPABASE_URL, serverEnv.SUPABASE_SERVICE_ROLE_KEY, { auth: { persistSession: false, autoRefreshToken: false }, }); } diff --git a/src/lib/supabase/browser.ts b/src/lib/supabase/browser.ts index 209be26..2c486df 100644 --- a/src/lib/supabase/browser.ts +++ b/src/lib/supabase/browser.ts @@ -1,10 +1,11 @@ import { createBrowserClient } from "@supabase/ssr"; +import type { Database } from "@/lib/database.types"; import { publicEnv } from "@/lib/env.public"; /** Anon-key client for the browser: Realtime subscriptions + the admin auth session. */ export function createClient() { - return createBrowserClient( + return createBrowserClient( publicEnv.NEXT_PUBLIC_SUPABASE_URL, publicEnv.NEXT_PUBLIC_SUPABASE_ANON_KEY, ); diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts index 28538f4..51a04fb 100644 --- a/src/lib/supabase/server.ts +++ b/src/lib/supabase/server.ts @@ -3,13 +3,14 @@ import "server-only"; import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; +import type { Database } from "@/lib/database.types"; import { publicEnv } from "@/lib/env.public"; /** Cookie-based client for admin server components/actions. RLS applies as the signed-in admin. */ export async function createClient() { const cookieStore = await cookies(); - return createServerClient( + return createServerClient( publicEnv.NEXT_PUBLIC_SUPABASE_URL, publicEnv.NEXT_PUBLIC_SUPABASE_ANON_KEY, {