diff --git a/app/layout.tsx b/app/layout.tsx index 60f62a8..251c9a1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ import { Instrument_Serif, Red_Hat_Display, } from "next/font/google"; +import { Toaster } from "@/components/ui/sonner"; import "./globals.css"; import { TooltipProvider } from "@/components/ui/tooltip"; import { AuthStateSync } from "@/components/auth-state-sync"; @@ -54,6 +55,7 @@ export default function RootLayout({ {children} + ); diff --git a/app/reserve/page.tsx b/app/reserve/page.tsx new file mode 100644 index 0000000..1450ed0 --- /dev/null +++ b/app/reserve/page.tsx @@ -0,0 +1,94 @@ +import Image from "next/image"; +import Link from "next/link"; +import { + getEvents, + getSignedInUser, + getTablesForEvent, + getTeams, +} from "@/lib/db/queries/reservation"; +import { ReservationBoard } from "@/components/reservation/reservation-board"; + +export const dynamic = "force-dynamic"; + +export default async function ReservePage({ + searchParams, +}: { + searchParams: Promise<{ event?: string }>; +}) { + const [events, user, teams, { event: eventParam }] = await Promise.all([ + getEvents(), + getSignedInUser(), + getTeams(), + searchParams, + ]); + + const selectedEvent = + events.find((e) => e.id === eventParam) ?? events[0] ?? null; + + const tables = selectedEvent ? await getTablesForEvent(selectedEvent.id) : []; + + return ( +
+
+
+ + MHacks + + MHacks 2026 + + + + Back to home + +
+
+ +
+
+

+ Reserve a Table +

+

+ Claim a spot in the judging area for your team. Select a table on + the map, or let us assign one at random. Reservations are final. +

+
+ + {events.length === 0 ? ( +
+

+ No events yet +

+

+ Seed some events with{" "} + + pnpm db:seed + {" "} + to start reserving tables. +

+
+ ) : ( + + )} +
+
+ ); +} diff --git a/components/reservation/event-picker.tsx b/components/reservation/event-picker.tsx new file mode 100644 index 0000000..06f182f --- /dev/null +++ b/components/reservation/event-picker.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { Event } from "@/lib/db/queries/reservation"; + +export function EventPicker({ + events, + selectedEventId, +}: { + events: Event[]; + selectedEventId: string; +}) { + const router = useRouter(); + + return ( + + ); +} diff --git a/components/reservation/judging-map.tsx b/components/reservation/judging-map.tsx new file mode 100644 index 0000000..767af72 --- /dev/null +++ b/components/reservation/judging-map.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { DEFAULT_COLUMNS, toRows } from "@/lib/reservation/layout"; +import type { TableWithTeam } from "@/lib/db/queries/reservation"; + +export type TableStatus = + | "available" + | "selected" + | "mine" + | "taken" + | "admin-target"; + +function statusOf( + table: TableWithTeam, + selectedTableId: string | null, + teamId: string | null, + adminMode: boolean, +): TableStatus { + if (table.id === selectedTableId) return "selected"; + if (adminMode && teamId && table.reservedByTeamId === teamId) { + return "admin-target"; + } + if (table.reservedByTeamId) { + return teamId && table.reservedByTeamId === teamId ? "mine" : "taken"; + } + return "available"; +} + +const seatStyles: Record = { + available: + "border-zinc-300 bg-white text-zinc-600 hover:border-[#445721] hover:bg-[#445721]/5 hover:text-[#3A4A26]", + selected: + "border-[#445721] bg-[#445721] text-white shadow-sm ring-2 ring-[#445721]/30", + mine: "border-[#445721]/50 bg-[#445721]/15 text-[#3A4A26] ring-1 ring-[#445721]/30", + taken: "border-zinc-200 bg-zinc-100 text-zinc-300", + "admin-target": + "border-amber-500/60 bg-amber-50 text-amber-900 ring-1 ring-amber-500/30", +}; + +export function JudgingMap({ + tables, + selectedTableId, + teamId, + onSelect, + disabled = false, + adminMode = false, +}: { + tables: TableWithTeam[]; + selectedTableId: string | null; + teamId: string | null; + onSelect: (table: TableWithTeam) => void; + disabled?: boolean; + adminMode?: boolean; +}) { + const rows = toRows(tables, DEFAULT_COLUMNS); + + return ( +
+
+
+
+
+ Judging Stage +
+
+ +
+ {rows.map((row, rowIndex) => ( +
+ {row.map((table) => { + const status = statusOf( + table, + selectedTableId, + teamId, + adminMode, + ); + const interactive = + !disabled && + (adminMode || + status === "available" || + status === "selected"); + + return ( + + ); + })} +
+ ))} +
+
+
+ + +
+ ); +} + +function Legend({ adminMode }: { adminMode: boolean }) { + const items: { label: string; className: string }[] = [ + { label: "Available", className: "border-zinc-300 bg-white" }, + { label: "Selected", className: "border-[#445721] bg-[#445721]" }, + { + label: "Your table", + className: "border-[#445721]/50 bg-[#445721]/15", + }, + { label: "Reserved", className: "border-zinc-200 bg-zinc-100" }, + ]; + + if (adminMode) { + items.push({ + label: "Selected team", + className: "border-amber-500/60 bg-amber-50", + }); + } + + return ( +
+ {items.map((item) => ( +
+ + {item.label} +
+ ))} +
+ ); +} diff --git a/components/reservation/reservation-board.tsx b/components/reservation/reservation-board.tsx new file mode 100644 index 0000000..2623ded --- /dev/null +++ b/components/reservation/reservation-board.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowRightLeft, Shuffle, Ticket } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { EventPicker } from "@/components/reservation/event-picker"; +import { JudgingMap } from "@/components/reservation/judging-map"; +import { + adminMoveTeamToTable, + randomlyAssignTable, + reserveTable, +} from "@/lib/actions/reservation"; +import type { + Event, + SignedInUser, + TableWithTeam, + Team, +} from "@/lib/db/queries/reservation"; + +export function ReservationBoard({ + events, + user, + teams, + tables, + selectedEventId, +}: { + events: Event[]; + user: SignedInUser | null; + teams: Team[]; + tables: TableWithTeam[]; + selectedEventId: string; +}) { + const router = useRouter(); + const [selectedTableId, setSelectedTableId] = useState(null); + const [adminTeamId, setAdminTeamId] = useState(""); + const [pendingAdminMove, setPendingAdminMove] = + useState(null); + const [isPending, startTransition] = useTransition(); + + const isAdmin = user?.isAdmin ?? false; + const teamId = isAdmin ? null : (user?.teamId ?? null); + const myTable = + tables.find((t) => teamId && t.reservedByTeamId === teamId) ?? null; + const selectedTable = tables.find((t) => t.id === selectedTableId) ?? null; + const hasReservation = myTable !== null; + const canReserve = !isAdmin && Boolean(teamId) && !hasReservation; + + const total = tables.length; + const reservedCount = tables.filter((t) => t.reservedByTeamId).length; + const openCount = total - reservedCount; + + const adminTeamName = teams.find((t) => t.id === adminTeamId)?.name ?? "team"; + const adminTeamCurrentTable = adminTeamId + ? (tables.find((t) => t.reservedByTeamId === adminTeamId) ?? null) + : null; + + function handleSelectTable(table: TableWithTeam) { + if (isAdmin) { + if (!adminTeamId) { + toast.error("Select a team first."); + return; + } + if (table.reservedByTeamId === adminTeamId) return; + setPendingAdminMove(table); + return; + } + + if (!canReserve) return; + setSelectedTableId(table.id); + } + + function handleConfirmAdminMove() { + if (!pendingAdminMove || !adminTeamId) return; + + startTransition(async () => { + const result = await adminMoveTeamToTable({ + teamId: adminTeamId, + tableId: pendingAdminMove.id, + }); + if (result.ok) { + toast.success(result.message); + setPendingAdminMove(null); + router.refresh(); + } else { + toast.error(result.error); + } + }); + } + + function handleReserve() { + if (!canReserve) { + toast.error("Your team already has a table for this event."); + return; + } + if (!selectedTable) { + toast.error("Select a table on the map first."); + return; + } + startTransition(async () => { + const result = await reserveTable({ tableId: selectedTable.id }); + if (result.ok) { + toast.success(result.message); + setSelectedTableId(null); + router.refresh(); + } else { + toast.error(result.error); + router.refresh(); + } + }); + } + + function handleRandom() { + if (!canReserve) { + toast.error("Your team already has a table for this event."); + return; + } + startTransition(async () => { + const result = await randomlyAssignTable({ eventId: selectedEventId }); + if (result.ok) { + toast.success(result.message); + setSelectedTableId(null); + router.refresh(); + } else { + toast.error(result.error); + } + }); + } + + return ( + <> +
+ + +
+ Judging area +
+ {openCount} open + {reservedCount} reserved +
+
+ + {isAdmin + ? "Select a team, then click a table to move or swap them." + : hasReservation + ? "Your table is locked in for this event." + : "Tap an open table to select it, then reserve."} + +
+ + + +
+ +
+ + +
+ + {isAdmin ? "Manage tables" : "Reserve your spot"} + + {isAdmin ? Admin : null} +
+ {user ? ( + + {user.name} + {!isAdmin && user.teamName ? ( + <> + {" "} + · Team{" "} + + {user.teamName} + + + ) : null} + + ) : null} +
+ +
+ + +
+ + + + {!user ? ( +
+ Signed-in user not found. Run{" "} + + pnpm db:seed + + . +
+ ) : isAdmin ? ( +
+
+ + +
+ {adminTeamId ? ( +
+ + Click a table to move{" "} + {teams.find((t) => t.id === adminTeamId)?.name ?? "team"} +
+ ) : ( +

+ Pick a team, then click a table on the map. Occupied + tables swap the two teams. +

+ )} +
+ ) : !teamId ? ( +
+ You're not on a team yet. +
+ ) : hasReservation ? ( +
+

+ Your team's table +

+

+ Table {myTable!.number} +

+

+ Reservations are final and cannot be changed. +

+
+ ) : ( +
+ {selectedTable + ? `Table ${selectedTable.number} selected — reserve it below.` + : "No table reserved yet."} +
+ )} + + {canReserve && ( +
+ + +
+ )} +
+
+
+
+ + { + if (!open && !isPending) setPendingAdminMove(null); + }} + > + + + Confirm table move + +
+ {pendingAdminMove?.reservedByTeamName ? ( + <> +

+ Swap {adminTeamName} + {adminTeamCurrentTable + ? ` (table ${adminTeamCurrentTable.number})` + : ""}{" "} + with{" "} + {pendingAdminMove.reservedByTeamName}{" "} + (table {pendingAdminMove.number})? +

+ + ) : ( +

+ Move {adminTeamName} + {adminTeamCurrentTable + ? ` from table ${adminTeamCurrentTable.number}` + : ""}{" "} + to table {pendingAdminMove?.number}? +

+ )} +
+
+
+ + + + +
+
+ + ); +} diff --git a/components/ui/card.tsx b/components/ui/card.tsx index 54c9e35..cbe7ed8 100644 --- a/components/ui/card.tsx +++ b/components/ui/card.tsx @@ -12,7 +12,7 @@ function Card({ data-slot="card" data-size={size} className={cn( - "group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", className, )} {...props} @@ -38,7 +38,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean; +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/drizzle/0001_chemical_morlun.sql b/drizzle/0001_chemical_morlun.sql new file mode 100644 index 0000000..8d75bf9 --- /dev/null +++ b/drizzle/0001_chemical_morlun.sql @@ -0,0 +1,30 @@ +CREATE TABLE "events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "description" text, + "starts_at" timestamp with time zone, + "location" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "tables" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "event_id" uuid NOT NULL, + "number" integer NOT NULL, + "reserved_by_team_id" uuid, + "reserved_at" timestamp with time zone, + CONSTRAINT "tables_event_number_unique" UNIQUE("event_id","number") +); +--> statement-breakpoint +CREATE TABLE "teams" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "teams_name_unique" UNIQUE("name") +); +--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "team_id" uuid;--> statement-breakpoint +ALTER TABLE "tables" ADD CONSTRAINT "tables_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tables" ADD CONSTRAINT "tables_reserved_by_team_id_teams_id_fk" FOREIGN KEY ("reserved_by_team_id") REFERENCES "public"."teams"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "tables_event_team_unique" ON "tables" USING btree ("event_id","reserved_by_team_id");--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..5fa20e8 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,247 @@ +{ + "id": "fce5bfbe-8e9b-487d-a49c-a5900b755049", + "prevId": "f2885ddd-1b72-4b1e-8461-4ba94da35928", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tables": { + "name": "tables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reserved_by_team_id": { + "name": "reserved_by_team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reserved_at": { + "name": "reserved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tables_event_team_unique": { + "name": "tables_event_team_unique", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reserved_by_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tables_event_id_events_id_fk": { + "name": "tables_event_id_events_id_fk", + "tableFrom": "tables", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tables_reserved_by_team_id_teams_id_fk": { + "name": "tables_reserved_by_team_id_teams_id_fk", + "tableFrom": "tables", + "tableTo": "teams", + "columnsFrom": ["reserved_by_team_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tables_event_number_unique": { + "name": "tables_event_number_unique", + "nullsNotDistinct": false, + "columns": ["event_id", "number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_name_unique": { + "name": "teams_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_team_id_teams_id_fk": { + "name": "users_team_id_teams_id_fk", + "tableFrom": "users", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..ed2b575 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1780883586302, + "tag": "0000_sparkling_slapstick", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1781414470602, + "tag": "0001_chemical_morlun", + "breakpoints": true + } + ] +} diff --git a/lib/actions/reservation.ts b/lib/actions/reservation.ts new file mode 100644 index 0000000..6569eda --- /dev/null +++ b/lib/actions/reservation.ts @@ -0,0 +1,252 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { and, eq, isNull } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { tables } from "@/lib/db/schema/reservation"; +import { getSignedInUser } from "@/lib/db/queries/reservation"; + +export type ActionResult = + | { ok: true; message?: string } + | { ok: false; error: string }; + +function shuffle(items: T[]): T[] { + const copy = [...items]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy; +} + +async function requireTeamId(): Promise< + { ok: true; teamId: string } | { ok: false; error: string } +> { + const user = await getSignedInUser(); + if (!user) { + return { ok: false, error: "Signed-in user not found. Run pnpm db:seed." }; + } + if (user.isAdmin) { + return { ok: false, error: "Admins cannot reserve tables." }; + } + if (!user.teamId) { + return { ok: false, error: "You're not on a team yet." }; + } + return { ok: true, teamId: user.teamId }; +} + +async function requireAdmin(): Promise< + { ok: true } | { ok: false; error: string } +> { + const user = await getSignedInUser(); + if (!user) { + return { ok: false, error: "Signed-in user not found. Run pnpm db:seed." }; + } + if (!user.isAdmin) { + return { ok: false, error: "Admin access required." }; + } + return { ok: true }; +} + +async function teamAlreadyReserved( + eventId: string, + teamId: string, +): Promise { + const existing = await db + .select({ id: tables.id }) + .from(tables) + .where( + and(eq(tables.eventId, eventId), eq(tables.reservedByTeamId, teamId)), + ) + .limit(1); + return existing.length > 0; +} + +export async function reserveTable({ + tableId, +}: { + tableId: string; +}): Promise { + const auth = await requireTeamId(); + if (!auth.ok) return auth; + const { teamId } = auth; + + const target = await db + .select({ id: tables.id, eventId: tables.eventId, number: tables.number }) + .from(tables) + .where(eq(tables.id, tableId)) + .limit(1); + + if (target.length === 0) { + return { ok: false, error: "That table no longer exists." }; + } + + const { eventId, number } = target[0]; + + if (await teamAlreadyReserved(eventId, teamId)) { + return { + ok: false, + error: "Your team already has a table for this event.", + }; + } + + const claimed = await db + .update(tables) + .set({ reservedByTeamId: teamId, reservedAt: new Date() }) + .where(and(eq(tables.id, tableId), isNull(tables.reservedByTeamId))) + .returning({ id: tables.id }); + + if (claimed.length === 0) { + return { ok: false, error: "That table was just taken. Pick another." }; + } + + revalidatePath("/reserve"); + return { ok: true, message: `Reserved table ${number}.` }; +} + +export async function randomlyAssignTable({ + eventId, +}: { + eventId: string; +}): Promise { + const auth = await requireTeamId(); + if (!auth.ok) return auth; + const { teamId } = auth; + + if (await teamAlreadyReserved(eventId, teamId)) { + return { + ok: false, + error: "Your team already has a table for this event.", + }; + } + + try { + const assignedNumber = await db.transaction(async (tx) => { + const open = await tx + .select({ id: tables.id, number: tables.number }) + .from(tables) + .where( + and(eq(tables.eventId, eventId), isNull(tables.reservedByTeamId)), + ); + + if (open.length === 0) { + throw new Error("FULL"); + } + + for (const candidate of shuffle(open)) { + const claimed = await tx + .update(tables) + .set({ reservedByTeamId: teamId, reservedAt: new Date() }) + .where( + and(eq(tables.id, candidate.id), isNull(tables.reservedByTeamId)), + ) + .returning({ id: tables.id }); + + if (claimed.length > 0) { + return candidate.number; + } + } + + throw new Error("FULL"); + }); + + revalidatePath("/reserve"); + return { ok: true, message: `Assigned table ${assignedNumber}.` }; + } catch (err) { + if (err instanceof Error && err.message === "FULL") { + return { ok: false, error: "No open tables left for this event." }; + } + return { ok: false, error: "Could not assign a table. Try again." }; + } +} + +export async function adminMoveTeamToTable({ + teamId, + tableId, +}: { + teamId: string; + tableId: string; +}): Promise { + const auth = await requireAdmin(); + if (!auth.ok) return auth; + + const [target] = await db + .select({ + id: tables.id, + eventId: tables.eventId, + number: tables.number, + reservedByTeamId: tables.reservedByTeamId, + }) + .from(tables) + .where(eq(tables.id, tableId)) + .limit(1); + + if (!target) { + return { ok: false, error: "That table no longer exists." }; + } + + if (target.reservedByTeamId === teamId) { + return { ok: true, message: `Team is already at table ${target.number}.` }; + } + + const [current] = await db + .select({ id: tables.id, number: tables.number }) + .from(tables) + .where( + and( + eq(tables.eventId, target.eventId), + eq(tables.reservedByTeamId, teamId), + ), + ) + .limit(1); + + const displacedTeamId = target.reservedByTeamId; + const now = new Date(); + + try { + await db.transaction(async (tx) => { + if (current) { + await tx + .update(tables) + .set({ reservedByTeamId: null, reservedAt: null }) + .where(eq(tables.id, current.id)); + } + if (displacedTeamId) { + await tx + .update(tables) + .set({ reservedByTeamId: null, reservedAt: null }) + .where(eq(tables.id, target.id)); + } + + await tx + .update(tables) + .set({ reservedByTeamId: teamId, reservedAt: now }) + .where(eq(tables.id, target.id)); + + if (displacedTeamId && current) { + await tx + .update(tables) + .set({ reservedByTeamId: displacedTeamId, reservedAt: now }) + .where(eq(tables.id, current.id)); + } + }); + } catch { + return { ok: false, error: "Could not move the team. Try again." }; + } + + revalidatePath("/reserve"); + + if (displacedTeamId && current) { + return { + ok: true, + message: `Swapped teams between tables ${current.number} and ${target.number}.`, + }; + } + if (displacedTeamId) { + return { + ok: true, + message: `Moved team to table ${target.number}. Previous occupant was unassigned.`, + }; + } + return { ok: true, message: `Moved team to table ${target.number}.` }; +} diff --git a/lib/db/index.ts b/lib/db/index.ts index 337d3c4..d731173 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -1,10 +1,14 @@ -import { drizzle } from "drizzle-orm/postgres-js"; +import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js"; import postgres from "postgres"; -import * as schema from "./schema/applications"; +import * as applicationsSchema from "./schema/applications"; +import * as reservationSchema from "./schema/reservation"; import * as usersSchema from "./schema/users"; // Disable prefetch — prepared statements are not supported in Supabase's // "Transaction" pool mode (the pooled connection string on port 6543). const client = postgres(process.env.DATABASE_URL ?? "", { prepare: false }); -export const db = drizzle({ client, schema: { ...schema, ...usersSchema } }); +export const db = drizzle({ + client, + schema: { ...applicationsSchema, ...reservationSchema, ...usersSchema }, +}); diff --git a/lib/db/queries/reservation.ts b/lib/db/queries/reservation.ts new file mode 100644 index 0000000..c98eea9 --- /dev/null +++ b/lib/db/queries/reservation.ts @@ -0,0 +1,76 @@ +import { asc, eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { events, tables, teams } from "@/lib/db/schema/reservation"; +import { users } from "@/lib/db/schema/users"; + +export type Event = typeof events.$inferSelect; +export type Team = typeof teams.$inferSelect; + +export type TableWithTeam = { + id: string; + number: number; + reservedByTeamId: string | null; + reservedByTeamName: string | null; +}; + +export type SignedInUser = { + id: string; + name: string; + teamId: string | null; + teamName: string | null; + isAdmin: boolean; +}; + +const TEMP_SIGNED_IN_USER = { + id: "00000000-0000-4000-8000-000000000001", + name: "Test User", +} as const; + +export function getEvents(): Promise { + return db + .select() + .from(events) + .orderBy(asc(events.startsAt), asc(events.name)); +} + +export function getTeams(): Promise { + return db.select().from(teams).orderBy(asc(teams.name)); +} + +export async function getSignedInUser(): Promise { + const rows = await db + .select({ + teamId: users.teamId, + teamName: teams.name, + isAdmin: users.isAdmin, + }) + .from(users) + .leftJoin(teams, eq(users.teamId, teams.id)) + .where(eq(users.id, TEMP_SIGNED_IN_USER.id)) + .limit(1); + + const row = rows[0]; + if (!row) return null; + + return { + id: TEMP_SIGNED_IN_USER.id, + name: TEMP_SIGNED_IN_USER.name, + teamId: row.teamId, + teamName: row.teamName, + isAdmin: row.isAdmin, + }; +} + +export function getTablesForEvent(eventId: string): Promise { + return db + .select({ + id: tables.id, + number: tables.number, + reservedByTeamId: tables.reservedByTeamId, + reservedByTeamName: teams.name, + }) + .from(tables) + .leftJoin(teams, eq(tables.reservedByTeamId, teams.id)) + .where(eq(tables.eventId, eventId)) + .orderBy(asc(tables.number)); +} diff --git a/lib/db/schema/applications.ts b/lib/db/schema/applications.ts index 279bd4f..63d5c5b 100644 --- a/lib/db/schema/applications.ts +++ b/lib/db/schema/applications.ts @@ -8,7 +8,7 @@ import { timestamp, jsonb, } from "drizzle-orm/pg-core"; -import { users } from "./users"; +import { users } from "./users.ts"; // Mirrors `ApplicationStatus` in lib/types/applications.ts export const applicationStatus = pgEnum("application_status", [ diff --git a/lib/db/schema/reservation.ts b/lib/db/schema/reservation.ts new file mode 100644 index 0000000..2f6b224 --- /dev/null +++ b/lib/db/schema/reservation.ts @@ -0,0 +1,51 @@ +import { + pgTable, + uuid, + text, + integer, + timestamp, + unique, + uniqueIndex, +} from "drizzle-orm/pg-core"; + +export const teams = pgTable("teams", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const events = pgTable("events", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + description: text("description"), + startsAt: timestamp("starts_at", { withTimezone: true }), + location: text("location"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const tables = pgTable( + "tables", + { + id: uuid("id").primaryKey().defaultRandom(), + eventId: uuid("event_id") + .notNull() + .references(() => events.id, { onDelete: "cascade" }), + number: integer("number").notNull(), + reservedByTeamId: uuid("reserved_by_team_id").references(() => teams.id, { + onDelete: "set null", + }), + reservedAt: timestamp("reserved_at", { withTimezone: true }), + }, + (t) => [ + unique("tables_event_number_unique").on(t.eventId, t.number), + uniqueIndex("tables_event_team_unique").on(t.eventId, t.reservedByTeamId), + ], +); + +export type Team = typeof teams.$inferSelect; +export type Event = typeof events.$inferSelect; +export type Table = typeof tables.$inferSelect; diff --git a/lib/db/schema/users.ts b/lib/db/schema/users.ts index a7853e5..0e09388 100644 --- a/lib/db/schema/users.ts +++ b/lib/db/schema/users.ts @@ -1,4 +1,5 @@ -import { pgTable, uuid, text } from "drizzle-orm/pg-core"; +import { pgTable, uuid, text, boolean } from "drizzle-orm/pg-core"; +import { teams } from "./reservation.ts"; export type UserRole = "hacker" | "organizer"; @@ -6,6 +7,8 @@ export const users = pgTable("users", { id: uuid("id").primaryKey(), email: text("email").notNull().unique(), role: text("role").notNull().default("hacker").$type(), + teamId: uuid("team_id").references(() => teams.id, { onDelete: "set null" }), + isAdmin: boolean("is_admin").notNull().default(false), }); export type UserEntry = typeof users.$inferSelect; diff --git a/lib/reservation/layout.ts b/lib/reservation/layout.ts new file mode 100644 index 0000000..2efae48 --- /dev/null +++ b/lib/reservation/layout.ts @@ -0,0 +1,16 @@ +export const DEFAULT_COLUMNS = 8; + +export function toRows( + items: T[], + columns: number = DEFAULT_COLUMNS, +): T[][] { + if (columns < 1) { + throw new Error("columns must be at least 1"); + } + + const rows: T[][] = []; + for (let i = 0; i < items.length; i += columns) { + rows.push(items.slice(i, i + columns)); + } + return rows; +} diff --git a/package.json b/package.json index aacf85a..4c69304 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "db:local:reset": "supabase db reset", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", - "db:push": "drizzle-kit push" + "db:push": "drizzle-kit push", + "db:seed": "node scripts/seed.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.1073.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d136130..5edefdc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7777,7 +7777,7 @@ snapshots: '@next/eslint-plugin-next': 16.1.6 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) @@ -7800,7 +7800,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -7815,14 +7815,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -7837,7 +7837,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..ef9dbfb --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,104 @@ +#!/usr/bin/env node +// Seed the local database with sample judging events, tables, teams, and a +// single admin user for testing reservations without auth. +// +// pnpm db:seed + +import "dotenv/config"; +import { and, eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { events, tables, teams } from "../lib/db/schema/reservation.ts"; +import { users } from "../lib/db/schema/users.ts"; + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + console.error( + "DATABASE_URL is not set. Start the stack (`pnpm db:local`) and run `pnpm db:env`.", + ); + process.exit(1); +} + +const TABLES_PER_EVENT = 40; + +const sampleEvents = [ + { + name: "MHacks 2026 — Final Judging", + description: "Top projects present to the judging panel.", + location: "Placeholder Hall — Room A", + startsAt: new Date("2026-10-04T13:00:00Z"), + }, + { + name: "MHacks 2026 — Track Demos", + description: "Open demos for AI, Sustainability, Healthcare, and Fintech.", + location: "Placeholder Hall — Room B", + startsAt: new Date("2026-10-04T16:00:00Z"), + }, +]; + +const sampleTeams = ["Team A", "Team B", "Team C"]; + +const client = postgres(connectionString, { prepare: false }); +const db = drizzle({ client }); + +async function main() { + console.log("Seeding reservation data…"); + + await db.delete(tables); + await db.delete(events); + await db.delete(teams); + + await db + .insert(users) + .values({ + id: "00000000-0000-4000-8000-000000000001", // TEMP_SIGNED_IN_USER in queries/reservation.ts + email: "test@local", + isAdmin: true, + }) + .onConflictDoUpdate({ + target: users.id, + set: { email: "test@local", isAdmin: true, teamId: null }, + }); + console.log(" + Test User (admin, no team)"); + + const insertedTeams = await db + .insert(teams) + .values(sampleTeams.map((name) => ({ name }))) + .returning({ id: teams.id, name: teams.name }); + + const insertedEvents = await db + .insert(events) + .values(sampleEvents) + .returning({ id: events.id, name: events.name }); + + for (const event of insertedEvents) { + const rows = Array.from({ length: TABLES_PER_EVENT }, (_, i) => ({ + eventId: event.id, + number: i + 1, + })); + await db.insert(tables).values(rows); + console.log(` + ${TABLES_PER_EVENT} tables for "${event.name}"`); + + for (let i = 0; i < insertedTeams.length; i++) { + await db + .update(tables) + .set({ + reservedByTeamId: insertedTeams[i].id, + reservedAt: new Date(), + }) + .where(and(eq(tables.eventId, event.id), eq(tables.number, i + 1))); + } + } + + console.log(` + ${insertedTeams.length} teams (sample reserved)`); + console.log("Done."); +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(async () => { + await client.end(); + }); diff --git a/supabase/migrations/0003_graceful_black_widow.sql b/supabase/migrations/20260614183803_applicant_user_fk.sql similarity index 100% rename from supabase/migrations/0003_graceful_black_widow.sql rename to supabase/migrations/20260614183803_applicant_user_fk.sql diff --git a/supabase/migrations/20260628000000_sync_application_schema.sql b/supabase/migrations/20260628000000_sync_application_schema.sql new file mode 100644 index 0000000..7d4afb4 --- /dev/null +++ b/supabase/migrations/20260628000000_sync_application_schema.sql @@ -0,0 +1,60 @@ +-- Align the local database with lib/db/schema/ so db:push does not need +-- interactive column-rename prompts (why_attend → what_would_you_do, etc.). + +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "role" text DEFAULT 'hacker' NOT NULL; + +-- judge_applicants missed the earlier cleanup migrations that only touched hacker_applicants. +UPDATE "judge_applicants" + SET "gender" = "gender_other" + WHERE "gender" = 'other' AND COALESCE("gender_other", '') <> ''; +UPDATE "judge_applicants" + SET "ethnicity" = "ethnicity_other" + WHERE "ethnicity" = 'multiracial' AND COALESCE("ethnicity_other", '') <> ''; +UPDATE "judge_applicants" + SET "university" = "university_other" + WHERE "university" = 'other' AND COALESCE("university_other", '') <> ''; +UPDATE "judge_applicants" + SET "country" = "country_other" + WHERE "country" = 'other' AND COALESCE("country_other", '') <> ''; +UPDATE "judge_applicants" + SET "degree" = "degree_other" + WHERE "degree" = 'other' AND COALESCE("degree_other", '') <> ''; +UPDATE "judge_applicants" + SET "major" = "major_other" + WHERE "major" = 'other' AND COALESCE("major_other", '') <> ''; + +ALTER TABLE "judge_applicants" + DROP COLUMN IF EXISTS "gender_other", + DROP COLUMN IF EXISTS "ethnicity_other", + DROP COLUMN IF EXISTS "university_other", + DROP COLUMN IF EXISTS "country_other", + DROP COLUMN IF EXISTS "degree_other", + DROP COLUMN IF EXISTS "major_other", + DROP COLUMN IF EXISTS "mlh_code_of_conduct", + DROP COLUMN IF EXISTS "mlh_privacy_policy", + DROP COLUMN IF EXISTS "mlh_emails", + DROP COLUMN IF EXISTS "has_allergies"; + +-- Essay prompts were renamed in the application form. +ALTER TABLE "hacker_applicants" RENAME COLUMN "why_attend" TO "what_would_you_do"; +ALTER TABLE "hacker_applicants" RENAME COLUMN "technical_challenge" TO "why_mhacks"; +ALTER TABLE "hacker_applicants" RENAME COLUMN "proud_project" TO "hill_to_die_on"; +ALTER TABLE "hacker_applicants" DROP COLUMN IF EXISTS "anything_else"; + +ALTER TABLE "judge_applicants" RENAME COLUMN "why_attend" TO "what_would_you_do"; +ALTER TABLE "judge_applicants" RENAME COLUMN "technical_challenge" TO "why_mhacks"; +ALTER TABLE "judge_applicants" RENAME COLUMN "proud_project" TO "hill_to_die_on"; +ALTER TABLE "judge_applicants" DROP COLUMN IF EXISTS "anything_else"; + +CREATE TABLE IF NOT EXISTS "hacker_application_drafts" ( + "user_id" uuid PRIMARY KEY NOT NULL, + "data" jsonb DEFAULT '{}'::jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +ALTER TABLE "hacker_application_drafts" + DROP CONSTRAINT IF EXISTS "hacker_application_drafts_user_id_users_id_fk"; +ALTER TABLE "hacker_application_drafts" + ADD CONSTRAINT "hacker_application_drafts_user_id_users_id_fk" + FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; diff --git a/supabase/migrations/20260629000000_table_reservations.sql b/supabase/migrations/20260629000000_table_reservations.sql new file mode 100644 index 0000000..02ddcc7 --- /dev/null +++ b/supabase/migrations/20260629000000_table_reservations.sql @@ -0,0 +1,51 @@ +-- Table reservation system: teams, judging events, and per-event table slots. + +CREATE TABLE IF NOT EXISTS "teams" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "teams_name_unique" UNIQUE("name") +); + +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "team_id" uuid, + ADD COLUMN IF NOT EXISTS "is_admin" boolean DEFAULT false NOT NULL; + +ALTER TABLE "users" + DROP CONSTRAINT IF EXISTS "users_team_id_teams_id_fk"; +ALTER TABLE "users" + ADD CONSTRAINT "users_team_id_teams_id_fk" + FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE set null ON UPDATE no action; + +CREATE TABLE IF NOT EXISTS "events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "description" text, + "starts_at" timestamp with time zone, + "location" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE IF NOT EXISTS "tables" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "event_id" uuid NOT NULL, + "number" integer NOT NULL, + "reserved_by_team_id" uuid, + "reserved_at" timestamp with time zone, + CONSTRAINT "tables_event_number_unique" UNIQUE("event_id", "number") +); + +ALTER TABLE "tables" + DROP CONSTRAINT IF EXISTS "tables_event_id_events_id_fk"; +ALTER TABLE "tables" + ADD CONSTRAINT "tables_event_id_events_id_fk" + FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "tables" + DROP CONSTRAINT IF EXISTS "tables_reserved_by_team_id_teams_id_fk"; +ALTER TABLE "tables" + ADD CONSTRAINT "tables_reserved_by_team_id_teams_id_fk" + FOREIGN KEY ("reserved_by_team_id") REFERENCES "public"."teams"("id") ON DELETE set null ON UPDATE no action; + +CREATE UNIQUE INDEX IF NOT EXISTS "tables_event_team_unique" + ON "tables" ("event_id", "reserved_by_team_id"); diff --git a/supabase/migrations/meta/0003_snapshot.json b/supabase/migrations/meta/20260614183803_snapshot.json similarity index 100% rename from supabase/migrations/meta/0003_snapshot.json rename to supabase/migrations/meta/20260614183803_snapshot.json diff --git a/supabase/migrations/meta/_journal.json b/supabase/migrations/meta/_journal.json index 23cd9cd..9d72cb8 100644 --- a/supabase/migrations/meta/_journal.json +++ b/supabase/migrations/meta/_journal.json @@ -27,7 +27,7 @@ "idx": 3, "version": "7", "when": 1781747800859, - "tag": "0003_graceful_black_widow", + "tag": "20260614183803_applicant_user_fk", "breakpoints": true } ]