From c27a3890f89c833875584d263c28579355523bd6 Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Mon, 5 Oct 2026 10:14:29 +0200 Subject: [PATCH] Phase 12: share an event with co-hosts The owner shares an event by email; a co-host gets full access inside it but can't delete it or change who it's shared with. Shares are keyed by email so an admin can be added before their first login, and RLS (not the UI) is what enforces the owner-only parts. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 5 +- docs/BUILD_PLAN.md | 10 ++- docs/DATA_MODEL.md | 4 +- docs/PRD.md | 10 ++- scripts/check-rls.mjs | 2 +- src/app/admin/actions.ts | 49 ++++++++++- src/app/admin/events/[eventId]/page.tsx | 14 ++- src/app/admin/page.tsx | 12 ++- src/components/admin/cohosts-editor.tsx | 88 +++++++++++++++++++ src/components/admin/event-row-actions.tsx | 37 +++++--- src/lib/database.types.ts | 29 +++++++ src/lib/validation/admin.test.ts | 14 ++- src/lib/validation/admin.ts | 7 ++ supabase/migrations/0005_cohosts.sql | 99 ++++++++++++++++++++++ 14 files changed, 353 insertions(+), 27 deletions(-) create mode 100644 src/components/admin/cohosts-editor.tsx create mode 100644 supabase/migrations/0005_cohosts.sql diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 79f8407..1cc3dd1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,7 +8,7 @@ Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast |---|---|---| | `/login` | admin | Supabase magic link | | `/admin` | admin | Events list | -| `/admin/events/[eventId]` | admin | Setup: teams, games, criteria, judges, share links | +| `/admin/events/[eventId]` | admin | Setup: teams, games, criteria, judges, share links, co-hosts | | `/admin/events/[eventId]/live` | admin | Control room | | `/s/[scoreboardToken]` | public | Scoreboard | | `/j/[judgeToken]` | judge | Personal judge page | @@ -29,7 +29,8 @@ Admin mutations = server actions. `src/proxy.ts` (Next.js 16's name for middlewa - `admin.ts` — service role, **server-only** (`import 'server-only'`). Used by public/judge route handlers after token validation. ## Security model -- RLS ON for every table. Policies only for the authenticated event owner. Anon role has no policies → no direct table access. +- RLS ON for every table. Policies only for the authenticated event owner and the co-hosts the owner shared it with. Anon role has no policies → no direct table access. +- Co-hosts are rows in `event_cohosts`, matched on the email in the login token. They get the same access as the owner inside the event; only the owner can delete the event or change its co-hosts (enforced by RLS — the UI just hides those buttons). Sharing is refused for an email that isn't in `ADMIN_EMAILS`, since that person couldn't log in. - Public/judge access = token in URL → route handler validates with zod → service-role query scoped by that token. - Judge write checks: judge not revoked, game not locked/done, criterion belongs to judge's game, team belongs to the game's event, value within min/max and on step. (DB trigger also enforces range.) - Scoreboard API checks `scoreboard_visibility` and strips scores when `hidden`; returns `frozen_snapshot` when `frozen`. diff --git a/docs/BUILD_PLAN.md b/docs/BUILD_PLAN.md index 4ef14c4..36e72b7 100644 --- a/docs/BUILD_PLAN.md +++ b/docs/BUILD_PLAN.md @@ -81,5 +81,13 @@ show who was first within a second, and "Clear buzzers" resets both phones. **Done when:** team A buzzes then team B; "Wrong" hands the turn to B; "Got it" gives B one point in the scoring grid and on the scoreboard; "Undo" takes it back; "Next question" resets the phones. +## Phase 12 — Co-hosts +- Apply `supabase/migrations/0005_cohosts.sql`, then `npm run gen:types` and `npm run check:rls`. +- `event_cohosts` table; RLS widened from owner to owner-or-co-host, with delete and sharing owner-only. +- "Co-hosts" section on the setup page (`addCohost` / `removeCohost` server actions); "Shared with you" in the events list. +**Done when:** admin A shares an event with admin B's email; B sees it in their list, can edit it and +run the control room alongside A, but has no Delete button and can't change the co-hosts; removing B +makes the event disappear from B's list. + ## Stretch (only if asked) -Participants within teams · co-host admins · CSV export of results. +Participants within teams · CSV export of results. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 6120ace..ea2a410 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -1,6 +1,6 @@ # Data model -Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `0003_buzzer.sql`, `0004_buzz_result.sql`). +Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `0003_buzzer.sql`, `0004_buzz_result.sql`, `0005_cohosts.sql`). | Table | Purpose | Key columns | |---|---|---| @@ -12,6 +12,7 @@ Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `000 | `scores` | One value per (team, criterion, judge) | `judge_id` NULL = admin score (direct mode or override) | | `adjustments` | Bonus/penalty | `points` (+/-), `reason`, optional `game_id` | | `buzzes` | Who buzzed for the current question | `game_id`, `team_id`, `buzzed_at`, `result` (correct/wrong/NULL) | +| `event_cohosts` | Admins an event is shared with | `event_id`, `email` (lower case), unique together | ## Notes - `scores` unique on `(team_id, criterion_id, judge_id)` with `NULLS NOT DISTINCT`, so there's exactly one admin row per team+criterion → clean upserts. @@ -20,4 +21,5 @@ Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `000 - Timer (`0002_timer.sql`): running = `timer_ends_at` set; paused = `timer_remaining_ms` set; reset = both null (full `timer_duration_ms`). - Buzzers (`0003_buzzer.sql`): `buzzes` is unique on `(game_id, team_id)` — one buzz per team per question; `buzzed_at` defaults to `clock_timestamp()` and decides the order. Clearing the buzzers deletes the game's rows. A trigger checks the team is in the game's event. `teams.buzzer_token` is a 128-bit token like the others; duplicating an event gives the copied teams new ones. - Buzzer answers (`0004_buzz_result.sql`): `buzzes.result` is the host's mark. The team answering is the earliest buzz with `result IS NULL`, unless some buzz is `correct` (question decided). The point for a correct answer is not stored on the buzz: it is +1 on the admin's `scores` row for the game's first criterion. +- Co-hosts (`0005_cohosts.sql`): a share is keyed by email, not user id, so an admin can be added before they have ever logged in. RLS matches it against the email in the login token. `is_event_admin(event)` = owner or co-host and guards every table inside an event; deleting the event and editing `event_cohosts` stay owner-only. A trigger stops `events.owner_id` from changing. - `frozen_snapshot` holds the `ScoreboardPayload` captured at freeze time. diff --git a/docs/PRD.md b/docs/PRD.md index 63f8044..174a2af 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -7,7 +7,7 @@ live scoreboard, and judges score from their phones via links. Versatile enough ## 2. Roles | Role | Access | How they get in | |---|---|---| -| **Admin** | Everything | Logs in (magic link). Email must be in `ADMIN_EMAILS`. | +| **Admin** | Everything in the events they own or co-host | Logs in (magic link). Email must be in `ADMIN_EMAILS`. | | **Scoreboard viewer** | Read-only scoreboard for ONE event | Public link `/s/{scoreboardToken}` | | **Judge** | Score ONE game | Personal link `/j/{judgeToken}` OR open game link `/jo/{openToken}` (enters name first) | | **Team** | Buzz in for ONE team | Team buzzer link `/b/{buzzerToken}`, one per team for the whole night | @@ -46,6 +46,12 @@ live scoreboard, and judges score from their phones via links. Versatile enough - Revoke a judge (link stops working, their scores excluded). - Share panel: scoreboard link + QR, all judge links per game, and one buzzer link + QR per team (shown once any game uses buzzers). +- Co-hosts: the admin who created an event (its owner) shares it with other admins by email. + - The email must already be in `ADMIN_EMAILS`; it can be added before that admin's first login. + - A co-host sees the event in their list ("Shared with you") and can do everything in it — + setup, control room, scores — except delete it or change who it is shared with. + - The owner can remove a co-host at any time. Duplicating a shared event gives the co-host + their own copy, which they own. ### 4.2 Admin — Control room (`/admin/events/[id]/live`) This is the screen used during the night. Must be fast and hard to mess up. @@ -152,7 +158,7 @@ For "first to answer" games. A game opts in with the "Teams buzz in" checkbox. ## 6. Out of scope (v1) - Individual contestants within teams (stretch: `participants` per team). -- Multiple admins / co-hosts (stretch). +- Transferring an event to another owner, and read-only co-hosts. - Per-game timers (stretch). The event countdown timer is in 4.5; team buzzers are in 4.7. - Buzzers for individual contestants, a configurable points value per correct answer, and penalties for a wrong answer (use the bonus/penalty panel). diff --git a/scripts/check-rls.mjs b/scripts/check-rls.mjs index 964d2d4..2be880e 100644 --- a/scripts/check-rls.mjs +++ b/scripts/check-rls.mjs @@ -2,7 +2,7 @@ // Run with: npm run check:rls import { createClient } from "@supabase/supabase-js"; -const TABLES = ["events", "teams", "games", "criteria", "judges", "scores", "adjustments", "buzzes"]; +const TABLES = ["events", "teams", "games", "criteria", "judges", "scores", "adjustments", "buzzes", "event_cohosts"]; const url = process.env.NEXT_PUBLIC_SUPABASE_URL; const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts index 5e2fe94..21affb7 100644 --- a/src/app/admin/actions.ts +++ b/src/app/admin/actions.ts @@ -4,10 +4,12 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { requireAdmin } from "@/lib/auth"; +import { serverEnv } from "@/lib/env.server"; import { criteriaPresets, type PresetKey } from "@/lib/presets"; import { notify } from "@/lib/realtime"; import { idSchema } from "@/lib/tokens"; import { + cohostEmailSchema, eventSchema, firstError, gameSchema, @@ -23,6 +25,7 @@ const dbFail = (what: string, message: string) => fail(`${what} failed: ${messag const validIds = (...ids: string[]) => ids.every((id) => idSchema.safeParse(id).success); const BAD_ID = fail("That item no longer exists. Reload the page."); +const OWNER_ONLY = fail("Only the person who created this game night can do that."); const eventPath = (eventId: string) => `/admin/events/${eventId}`; @@ -85,13 +88,57 @@ export async function deleteEvent(eventId: string): Promise { const { supabase } = await requireAdmin(); if (!validIds(eventId)) return BAD_ID; - const { error } = await supabase.from("events").delete().eq("id", eventId); + // RLS lets only the owner delete; for a co-host the delete matches nothing instead of failing. + const { data, error } = await supabase.from("events").delete().eq("id", eventId).select("id"); if (error) return dbFail("Deleting the event", error.message); + if (data.length === 0) return OWNER_ONLY; revalidatePath("/admin"); return ok; } +// ---------- co-hosts ---------- + +/** Shares the event with another admin, by email. Owner only (RLS enforces it). */ +export async function addCohost(eventId: string, email: string): Promise { + const { supabase, email: ownEmail } = await requireAdmin(); + if (!validIds(eventId)) return BAD_ID; + const parsed = cohostEmailSchema.safeParse(email); + if (!parsed.success) return fail(firstError(parsed.error)); + const cohostEmail = parsed.data; + + if (cohostEmail === ownEmail) return fail("That's your own email — you already have access."); + // Sharing with someone who can't log in would look like it worked and then not work. + if (!serverEnv.ADMIN_EMAILS.includes(cohostEmail)) { + return fail(`${cohostEmail} can't log in yet. Add it to ADMIN_EMAILS first, then share again.`); + } + + const { error } = await supabase.from("event_cohosts").insert({ event_id: eventId, email: cohostEmail }); + if (error?.code === "23505") return fail(`Already shared with ${cohostEmail}.`); + if (error?.code === "42501") return OWNER_ONLY; + if (error) return dbFail("Sharing the event", error.message); + + revalidatePath(eventPath(eventId)); + return ok; +} + +export async function removeCohost(eventId: string, cohostId: string): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId, cohostId)) return BAD_ID; + + const { data, error } = await supabase + .from("event_cohosts") + .delete() + .eq("id", cohostId) + .eq("event_id", eventId) + .select("id"); + if (error) return dbFail("Removing the co-host", error.message); + if (data.length === 0) return OWNER_ONLY; + + revalidatePath(eventPath(eventId)); + return ok; +} + /** Copies teams, games and criteria — not scores, judges or adjustments. */ export async function duplicateEvent(eventId: string): Promise { const { supabase, userId } = await requireAdmin(); diff --git a/src/app/admin/events/[eventId]/page.tsx b/src/app/admin/events/[eventId]/page.tsx index d737c02..5044e98 100644 --- a/src/app/admin/events/[eventId]/page.tsx +++ b/src/app/admin/events/[eventId]/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; +import { CohostsEditor } from "@/components/admin/cohosts-editor"; import { EventDetailsForm } from "@/components/admin/event-details-form"; import { GamesEditor } from "@/components/admin/games-editor"; import { SharePanel } from "@/components/admin/share-panel"; @@ -11,12 +12,16 @@ import { loadEventSetup } from "@/lib/queries/event"; import { idSchema } from "@/lib/tokens"; export default async function EventSetupPage(props: PageProps<"/admin/events/[eventId]">) { - const { supabase } = await requireAdmin(); + const { supabase, userId } = await requireAdmin(); const { eventId } = await props.params; if (!idSchema.safeParse(eventId).success) notFound(); - const setup = await loadEventSetup(supabase, eventId); + const [setup, cohosts] = await Promise.all([ + loadEventSetup(supabase, eventId), + supabase.from("event_cohosts").select("id, email").eq("event_id", eventId).order("created_at"), + ]); if (!setup) notFound(); + if (cohosts.error) throw new Error(`Could not load the co-hosts: ${cohosts.error.message}`); const { event, teams, games } = setup; return ( @@ -65,6 +70,11 @@ export default async function EventSetupPage(props: PageProps<"/admin/events/[ev

Share links

+ +
+

Co-hosts

+ +
); } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index a4c9c32..9a665f6 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -7,10 +7,11 @@ import { requireAdmin } from "@/lib/auth"; const dateFormat = new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeZone: "UTC" }); export default async function AdminHome() { - const { supabase } = await requireAdmin(); + const { supabase, userId } = await requireAdmin(); + // RLS returns the events you own plus the ones shared with you. const { data: events, error } = await supabase .from("events") - .select("id, name, event_date") + .select("id, name, event_date, owner_id") .order("created_at", { ascending: false }); if (error) throw new Error(`Could not load events: ${error.message}`); @@ -33,9 +34,14 @@ export default async function AdminHome() { {event.event_date ? dateFormat.format(new Date(event.event_date)) : "No date set"} + {event.owner_id !== userId && " · Shared with you"} - + ))} diff --git a/src/components/admin/cohosts-editor.tsx b/src/components/admin/cohosts-editor.tsx new file mode 100644 index 0000000..99b4f38 --- /dev/null +++ b/src/components/admin/cohosts-editor.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState } from "react"; + +import { addCohost, removeCohost } from "@/app/admin/actions"; +import { Button } from "@/components/ui/button"; +import { inputClass } from "@/components/ui/field"; +import type { Tables } from "@/lib/database.types"; + +import { ErrorText } from "./error-text"; +import { useAction } from "./use-action"; + +type Cohost = Pick, "id" | "email">; + +/** Who the event is shared with. Only the owner can change the list; co-hosts just see it. */ +export function CohostsEditor({ + eventId, + cohosts, + isOwner, +}: { + eventId: string; + cohosts: Cohost[]; + isOwner: boolean; +}) { + const [email, setEmail] = useState(""); + const { pending, error, run } = useAction(); + + return ( +
+

+ {isOwner + ? "Co-hosts can set up and run this game night with you. They can't delete it or change who it's shared with." + : "This game night was shared with you. Only the person who created it can change who it's shared with."} +

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

Not shared with anyone yet.

+ ) : ( +
    + {cohosts.map((cohost) => ( +
  • + {cohost.email} + {isOwner && ( + + )} +
  • + ))} +
+ )} + + {isOwner && ( +
{ + event.preventDefault(); + run(() => addCohost(eventId, email), () => setEmail("")); + }} + > + setEmail(event.target.value)} + required + maxLength={254} + placeholder="name@example.com" + className={`${inputClass} max-w-xs`} + /> + +
+ )} + {error} +
+ ); +} diff --git a/src/components/admin/event-row-actions.tsx b/src/components/admin/event-row-actions.tsx index c104db5..473d541 100644 --- a/src/components/admin/event-row-actions.tsx +++ b/src/components/admin/event-row-actions.tsx @@ -6,7 +6,16 @@ import { Button } from "@/components/ui/button"; import { ErrorText } from "./error-text"; import { useAction } from "./use-action"; -export function EventRowActions({ eventId, eventName }: { eventId: string; eventName: string }) { +export function EventRowActions({ + eventId, + eventName, + canDelete, +}: { + eventId: string; + eventName: string; + /** Only the owner can delete; the server enforces it too. */ + canDelete: boolean; +}) { const { pending, error, run } = useAction(); return ( @@ -19,18 +28,20 @@ export function EventRowActions({ eventId, eventName }: { eventId: string; event > Duplicate - + {canDelete && ( + + )} {error} ); diff --git a/src/lib/database.types.ts b/src/lib/database.types.ts index 4e3dc90..ece4a90 100644 --- a/src/lib/database.types.ts +++ b/src/lib/database.types.ts @@ -144,6 +144,35 @@ export type Database = { }, ]; }; + event_cohosts: { + Row: { + created_at: string; + email: string; + event_id: string; + id: string; + }; + Insert: { + created_at?: string; + email: string; + event_id: string; + id?: string; + }; + Update: { + created_at?: string; + email?: string; + event_id?: string; + id?: string; + }; + Relationships: [ + { + foreignKeyName: "event_cohosts_event_id_fkey"; + columns: ["event_id"]; + isOneToOne: false; + referencedRelation: "events"; + referencedColumns: ["id"]; + }, + ]; + }; events: { Row: { created_at: string; diff --git a/src/lib/validation/admin.test.ts b/src/lib/validation/admin.test.ts index 4e0599b..a09e11e 100644 --- a/src/lib/validation/admin.test.ts +++ b/src/lib/validation/admin.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { criteriaPresets } from "@/lib/presets"; -import { criterionSchema, gameSchema, parsePlacementPoints, type GameDraft } from "./admin"; +import { cohostEmailSchema, criterionSchema, gameSchema, parsePlacementPoints, type GameDraft } from "./admin"; const criterion = { label: "Pitch", description: "", inputType: "blocks", min: 1, max: 5, step: 1, weight: 1 } as const; @@ -65,6 +65,18 @@ describe("gameSchema", () => { }); }); +describe("cohostEmailSchema", () => { + it("trims and lower-cases, so it matches the email in the login token", () => { + expect(cohostEmailSchema.parse(" Sam@Example.COM ")).toBe("sam@example.com"); + }); + + it("rejects things that aren't an email", () => { + expect(cohostEmailSchema.safeParse("").success).toBe(false); + expect(cohostEmailSchema.safeParse("sam").success).toBe(false); + expect(cohostEmailSchema.safeParse("sam@example.com, kim@example.com").success).toBe(false); + }); +}); + describe("parsePlacementPoints", () => { it("splits on commas and spaces", () => { expect(parsePlacementPoints("10, 7,5 3")).toEqual([10, 7, 5, 3]); diff --git a/src/lib/validation/admin.ts b/src/lib/validation/admin.ts index 3d92e8f..1067b21 100644 --- a/src/lib/validation/admin.ts +++ b/src/lib/validation/admin.ts @@ -22,6 +22,13 @@ export const eventSchema = z.object({ eventDate: z.union([z.iso.date("Pick a valid date."), z.literal("")]), }); +/** A co-host's email, normalised the way event_cohosts stores it (trimmed, lower case). */ +export const cohostEmailSchema = z + .string() + .trim() + .toLowerCase() + .pipe(z.email("Enter a valid email address.").max(254, "That email is too long.")); + export const teamSchema = z.object({ name: text(60, "Team name"), color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "Pick a colour."), diff --git a/supabase/migrations/0005_cohosts.sql b/supabase/migrations/0005_cohosts.sql new file mode 100644 index 0000000..e981a75 --- /dev/null +++ b/supabase/migrations/0005_cohosts.sql @@ -0,0 +1,99 @@ +-- Game Night Live — sharing an event with other admins (co-hosts). +-- Run after 0004_buzz_result.sql. +-- +-- The owner shares an event by email. A co-host can do everything in the event (setup, control +-- room, scores) except delete it or change who it is shared with. +-- +-- Shares are keyed by email, not user id, so an admin can be added before their first login. +-- The email comes from the signed JWT, which is only as trustworthy as the project's auth +-- settings: keep "Confirm email" on (magic links confirm it anyway). ADMIN_EMAILS relies on +-- the same claim. + +create table event_cohosts ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references events(id) on delete cascade, + email text not null check (email = lower(email) and length(email) between 3 and 254), + created_at timestamptz not null default now(), + constraint event_cohosts_one_per_email unique (event_id, email) +); +create index on event_cohosts(email); + +-- ---------- helpers ---------- +-- security definer: they read events / event_cohosts without going back through RLS. + +create or replace function is_event_cohost(eid uuid) returns boolean +language sql stable security definer set search_path = public as $$ + select exists ( + select 1 from event_cohosts + where event_id = eid and email = lower(auth.jwt() ->> 'email') + ) +$$; + +create or replace function is_event_admin(eid uuid) returns boolean +language sql stable security definer set search_path = public as $$ + select is_event_owner(eid) or is_event_cohost(eid) +$$; + +-- A co-host may update the event row, so the policy can't be what protects the owner column. +create or replace function keep_event_owner() returns trigger +language plpgsql as $$ +begin + if new.owner_id is distinct from old.owner_id then + raise exception 'the owner of an event cannot be changed'; + end if; + return new; +end $$; + +create trigger events_keep_owner +before update on events +for each row execute function keep_event_owner(); + +-- ---------- RLS ---------- +-- Still no anon policies anywhere. + +alter table event_cohosts enable row level security; + +-- Co-hosts can see who else is on the event; only the owner changes the list. +create policy admin_read on event_cohosts for select to authenticated + using (is_event_admin(event_id)); +create policy owner_insert on event_cohosts for insert to authenticated + with check (is_event_owner(event_id)); +create policy owner_delete on event_cohosts for delete to authenticated + using (is_event_owner(event_id)); + +-- events: the owner check stays on the column (not is_event_admin) so that +-- INSERT ... RETURNING can see the row it has just inserted. +drop policy owner_all on events; +create policy admin_read on events for select to authenticated + using (owner_id = auth.uid() or is_event_cohost(id)); +create policy owner_insert on events for insert to authenticated + with check (owner_id = auth.uid()); +create policy admin_update on events for update to authenticated + using (owner_id = auth.uid() or is_event_cohost(id)) + with check (owner_id = auth.uid() or is_event_cohost(id)); +create policy owner_delete on events for delete to authenticated + using (owner_id = auth.uid()); + +-- everything inside an event: owner or co-host +drop policy owner_all on teams; +drop policy owner_all on games; +drop policy owner_all on adjustments; +drop policy owner_all on criteria; +drop policy owner_all on judges; +drop policy owner_all on scores; +drop policy owner_all on buzzes; + +create policy admin_all on teams for all to authenticated + using (is_event_admin(event_id)) with check (is_event_admin(event_id)); +create policy admin_all on games for all to authenticated + using (is_event_admin(event_id)) with check (is_event_admin(event_id)); +create policy admin_all on adjustments for all to authenticated + using (is_event_admin(event_id)) with check (is_event_admin(event_id)); +create policy admin_all on criteria for all to authenticated + using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id))); +create policy admin_all on judges for all to authenticated + using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id))); +create policy admin_all on scores for all to authenticated + using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id))); +create policy admin_all on buzzes for all to authenticated + using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id)));