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,
{