A game can now carry a list of items the host puts on the scoreboard one at a time: a question (shown above the standings, with an optional picture and an answer to reveal), a sound (played through the scoreboard's speakers) or a video such as a karaoke track (takes over the screen). - Media is a YouTube link, an https link to a file, or an upload to the private game-media bucket. The scoreboard API signs a URL only for the item that is on screen. - What is on screen is state on the event, like the timer, so the ping still carries no data. An unrevealed answer and a sound's or video's name never reach the scoreboard payload. - Control room: click an item to show it, or "Random" to have one picked (prefers items not played yet). Show/hide answer, pause, take off screen; shortcuts Q / A / X. - Duplicating a game or event copies its items and files; deleting removes the files. Needs supabase/migrations/0006_screen_items.sql applied before deploying. database.types.ts was edited by hand to match; re-run gen:types after. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
28 lines
3.8 KiB
Markdown
28 lines
3.8 KiB
Markdown
# Data model
|
|
|
|
Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `0003_buzzer.sql`, `0004_buzz_result.sql`, `0005_cohosts.sql`, `0006_screen_items.sql`).
|
|
|
|
| Table | Purpose | Key columns |
|
|
|---|---|---|
|
|
| `events` | A game night | `owner_id`, `scoreboard_token`, `scoreboard_visibility`, `frozen_snapshot`, `current_game_id`, `reveal_active`, `reveal_count`, `timer_visible`, `timer_duration_ms`, `timer_remaining_ms`, `timer_ends_at`, `screen_item_id`, `screen_shown_at`, `screen_answer_shown`, `screen_paused` |
|
|
| `teams` | Teams in an event | `name`, `color` (#RRGGBB), `sort_order`, `buzzer_token` |
|
|
| `games` | Activities | `scoring_mode`, `points_mode`, `multiplier`, `placement_points int[]`, `status`, `admin_notes`, `judge_notes`, `open_token`, `open_link_enabled`, `judging_locked`, `spotlight_team_id`, `buzzer_enabled` |
|
|
| `criteria` | Scoring blocks per game | `label`, `description`, `input_type`, `min_value`, `max_value`, `step`, `weight` |
|
|
| `judges` | Judges per game | `name`, `token`, `revoked`, `source` (nominated/open_link) |
|
|
| `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 |
|
|
| `game_items` | Questions, sounds and videos a game can put on the scoreboard | `game_id`, `kind` (question/sound/video), `title`, `answer`, `media_url`, `storage_path`, `sort_order`, `played_at` |
|
|
|
|
## 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.
|
|
- In **judged** games, an admin row (`judge_id IS NULL`) is an **override** for that cell.
|
|
- Deleting a criterion/team/judge cascades its scores.
|
|
- 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.
|
|
- On-screen items (`0006_screen_items.sql`): `title` is the question's text, or the host's name for a sound or video. Media is `media_url` (https link) or `storage_path` (a file in the private `game-media` storage bucket, `{event id}/{uuid}.{ext}`) — never both; a sound or video needs one of them. `played_at` is set when an item goes on screen and only feeds the random pick. `events.screen_item_id` is the item on screen (set null if the item is deleted); `screen_shown_at` changes on every show so screens restart it. The bucket has storage policies for the event's admins only, a 50 MB limit and audio/video/image types.
|
|
- `frozen_snapshot` holds the `ScoreboardPayload` captured at freeze time.
|