From 04015336a09f928e8a51042e8606d8c1a72d225d Mon Sep 17 00:00:00 2001 From: Sebastian Egger Date: Wed, 8 Jul 2026 17:44:32 +0200 Subject: [PATCH 1/2] Add reusable local Supabase dev stack Provider-agnostic local DB harness so any branch can develop and test against a local `supabase start` instance instead of prod: - supabase/config.toml: local stack config (Podman-friendly; analytics disabled since its vector shipper can't bind-mount the Docker socket) - supabase/migrations/: baseline migration (consolidated schema.sql) as the CLI source of truth for deterministic `supabase db reset`. Local-only - never pushed to prod, which already has this schema - supabase/seed_users.sql: two throwaway login accounts (dev + admin, password123) with auto-created profiles - SupabaseConfig: USE_LOCAL_SUPABASE dart-define switches local vs prod (defaults to prod, same URL/anon key as before) - .vscode/launch.json: web-server port-8080 launch configs - docs/DEV_SETUP.md: local stack walkthrough - .gitignore: ignore CLI runtime artifacts Seed trick catalog loads from import_tricks2.sql via config.toml. Auth- provider-specific config stays on feature branches. --- .gitignore | 8 +- .vscode/launch.json | 19 ++ docs/DEV_SETUP.md | 87 +++++ lib/supabase_config.dart | 19 +- supabase/config.toml | 52 +++ .../00000000000000_initial_schema.sql | 296 ++++++++++++++++++ supabase/seed_users.sql | 51 +++ 7 files changed, 527 insertions(+), 5 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/00000000000000_initial_schema.sql create mode 100644 supabase/seed_users.sql diff --git a/.gitignore b/.gitignore index 4ca4913..8c1d546 100644 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,10 @@ app.*.map.json .netlify # FVM Version Cache -.fvm/ \ No newline at end of file +.fvm/ + +# Supabase local stack (CLI-managed runtime artifacts; config.toml, +# migrations/ and seed.sql are committed, everything else is local-only) +supabase/.branches +supabase/.temp +supabase/.env \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d8fca97 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Flutter (Web server, port 8080)", + "request": "launch", + "type": "dart", + "deviceId": "web-server", + "args": ["--web-port=8080"] + }, + { + "name": "Flutter (Web server, port 8080, local Supabase)", + "request": "launch", + "type": "dart", + "deviceId": "web-server", + "args": ["--web-port=8080", "--dart-define=USE_LOCAL_SUPABASE=true"] + } + ] +} diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 0f79539..99fb7d7 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -1,5 +1,92 @@ # Dev Setup Notes +## Local Supabase Stack + +Test schema / RLS / RPC changes against a local database before running any +SQL against prod. This spins up a full local Supabase stack (Postgres, +GoTrue, PostgREST, Studio) in Docker. Nothing here touches the prod project. + +### One-time prerequisites + +- Supabase CLI: `brew install supabase/tap/supabase` +- A container runtime, either: + - Docker Desktop, **or** + - Podman (start the VM and point Docker's client API at Podman's socket, + since the Supabase CLI talks to whatever `DOCKER_HOST` points at): + ``` + podman machine init # first time only + podman machine start + export DOCKER_HOST=unix://$(podman machine inspect --format '{{.ConnectionInfo.PodmanSocket.Path}}') + ``` + Add the `export DOCKER_HOST=...` line to your shell profile so it's set + in every new terminal. If `supabase start` still can't find a runtime, + fall back to Docker Desktop or `colima` (`brew install colima && colima + start`). + +### Bringing the DB up + +The repo already tracks everything the CLI needs — `supabase/config.toml` +and `supabase/migrations/` — so no `supabase init` is required. Seed data +(the trick catalog) loads from `supabase/import_tricks2.sql`, wired via +`config.toml` → `[db.seed] sql_paths`. + +1. `supabase start` — boots the stack and prints the local API URL, DB URL, + and Studio URL. Defaults: + - API: `http://127.0.0.1:54321` + - DB: `postgresql://postgres:postgres@127.0.0.1:54322/postgres` + - Studio: `http://127.0.0.1:54323` +2. `supabase db reset` — drops and rebuilds the DB from + `supabase/migrations/` in order, then loads the seed (trick catalog). + Run this whenever migrations change to get a clean, deterministic DB. + +### Running the app against it + +``` +flutter run -d web-server --web-port=8080 --dart-define=USE_LOCAL_SUPABASE=true +``` + +`web-server` doesn't auto-open a browser — open `http://localhost:8080` +yourself once it's serving. + +Or use the "Flutter (Web server, port 8080, local Supabase)" VS Code launch +config. This flips `SupabaseConfig` (`lib/supabase_config.dart`) over to the +local instance's fixed default URL/anon key — nothing to hand-edit, and +nothing that risks getting committed pointed at the wrong project. Omit the +`--dart-define` to run against prod. + +### Schema source of truth + +`supabase/migrations/` is authoritative for the CLI (`db reset` / +`db push`). `supabase/schema.sql` is the human-readable consolidated +snapshot of the same schema — keep the two in sync when adding a migration. +The older loose `add_*.sql` / `migrate_*.sql` files are historical one-offs +already folded into the baseline migration; kept for reference only. + +Adding a schema change: create a new timestamped file under +`supabase/migrations/` (`supabase migration new `), then `supabase db +reset` to verify it applies cleanly locally before it ever reaches prod. + +**Prod already has the baseline.** `00000000000000_initial_schema.sql` is a +snapshot of the schema prod already runs — it is only replayed onto fresh +*local* DBs. Never `supabase db push` it to prod (its `create table`s would +conflict). When first linking migrations to the prod project, mark it as +already-applied so only *future* migrations push: +`supabase migration repair --status applied 00000000000000`. + +## Flutter Web Should Use Port 8080 + +The VS Code launch configs and the local Supabase auth redirect URLs +(`config.toml` → `[auth] additional_redirect_urls`) assume Flutter web runs +on port 8080. A random port (the default for `flutter run -d chrome`) can +break auth redirects locally. + +``` +flutter run -d web-server --web-port=8080 +``` + +The `.vscode/launch.json` "Flutter (Web server, port 8080)" configuration +does this automatically if you run/debug from VS Code instead of the CLI. + ## Testing Flutter Web on a Physical Device Run the dev server bound to all network interfaces so a phone on the same WiFi can reach it: diff --git a/lib/supabase_config.dart b/lib/supabase_config.dart index 8c780e9..13cee15 100644 --- a/lib/supabase_config.dart +++ b/lib/supabase_config.dart @@ -1,6 +1,17 @@ class SupabaseConfig { - // Replace these with your project values from: - // Supabase Dashboard → Project Settings → API - static const String url = 'https://ubsmdiesqofnfxuicwxj.supabase.co'; - static const String anonKey = 'sb_publishable_EHLEMf4zv4Im0u3O6cwhXA_WzIuNDT9'; + // Run with --dart-define=USE_LOCAL_SUPABASE=true to point at a local + // `supabase start` instance instead of prod. See docs/DEV_SETUP.md. + static const bool useLocal = bool.fromEnvironment('USE_LOCAL_SUPABASE'); + + static const String _prodUrl = 'https://ubsmdiesqofnfxuicwxj.supabase.co'; + static const String _prodAnonKey = 'sb_publishable_EHLEMf4zv4Im0u3O6cwhXA_WzIuNDT9'; + + // Fixed defaults every `supabase start` local instance uses out of the + // box - not a secret, safe to hardcode. + static const String _localUrl = 'http://127.0.0.1:54321'; + static const String _localAnonKey = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0'; + + static String get url => useLocal ? _localUrl : _prodUrl; + static String get anonKey => useLocal ? _localAnonKey : _prodAnonKey; } diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..62fcde1 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,52 @@ +# Local Supabase stack config, provider-agnostic. Auth-provider-specific +# settings (e.g. Cognito third-party auth) are layered on by feature +# branches, not committed here. Regenerate with `supabase init` if your CLI +# version rejects any field below. +project_id = "freestyle" + +[api] +enabled = true +port = 54321 +schemas = ["public", "graphql_public"] +extra_search_path = ["public", "extensions"] +max_rows = 1000 + +[db] +port = 54322 +shadow_port = 54320 +major_version = 15 + +# Seed runs through the CLI's SQL driver (not psql), so files must be plain +# SQL - no `\i` / meta-commands. Point directly at the data files. +# import_tricks2.sql is the fuller catalog (positions + 391 tricks). +[db.seed] +enabled = true +sql_paths = ["./import_tricks2.sql", "./seed_users.sql"] + +[studio] +enabled = true +port = 54323 + +[inbucket] +enabled = true +port = 54324 + +[auth] +enabled = true +site_url = "http://127.0.0.1:8080" +additional_redirect_urls = ["http://127.0.0.1:8080", "http://localhost:8080"] +jwt_expiry = 3600 +enable_signup = true + +[auth.email] +enable_signup = true +enable_confirmations = false + +[storage] +enabled = true +file_size_limit = "50MiB" + +# Analytics (logflare) + its vector log shipper bind-mount the Docker +# socket, which Podman can't mount as a volume. Not needed for local dev. +[analytics] +enabled = false diff --git a/supabase/migrations/00000000000000_initial_schema.sql b/supabase/migrations/00000000000000_initial_schema.sql new file mode 100644 index 0000000..a8a0a76 --- /dev/null +++ b/supabase/migrations/00000000000000_initial_schema.sql @@ -0,0 +1,296 @@ +-- ============================================================ +-- Freestyle Highline – Supabase Schema +-- Reflects full current state including all applied migrations. +-- Run this in the Supabase SQL Editor on a fresh project. +-- ============================================================ + +-- ============================================================ +-- Tables +-- ============================================================ + +-- Positions (e.g. Standing, Hanging, Sitting) +create table positions ( + id smallint generated always as identity primary key, + name text not null unique +); + +-- User profiles (extends auth.users) +-- flags: bit 0 = can edit tricks +create table profiles ( + int_id integer generated always as identity primary key, + id uuid unique not null references auth.users(id) on delete cascade, + username text unique, + flags smallint not null default 0 +); + +-- Tricks +-- flags: bit 0 = isCore, bit 1 = hasTrainingVideo +create table tricks ( + id integer generated always as identity primary key, + given_name text not null, + technical_name text, + difficulty_tier smallint not null check (difficulty_tier = -1 or difficulty_tier between 1 and 30), + date_submitted timestamptz not null default now(), + date_performed date, + original_performer text, + prerequisite_trick_ids integer[] not null default '{}', + base_trick_ids integer[] not null default '{}', + description text, + tips text, + video_link text, + video_start smallint, + video_end smallint, + start_position_id smallint references positions(id), + end_position_id smallint references positions(id), + status smallint not null default 0 check (status between 0 and 2), + submitted_by integer references profiles(int_id) on delete set null, + flags smallint not null default 0 +); + +-- User trick tracking +create table user_tricks ( + id integer generated always as identity primary key, + user_id integer not null references profiles(int_id) on delete cascade, + trick_id integer not null references tricks(id) on delete cascade, + consistency smallint not null default 0 check (consistency between 0 and 5), + difficulty_vote smallint check (difficulty_vote between 1 and 30), + leash_position smallint check (leash_position between 0 and 2), + video_link text, + video_start smallint, + video_end smallint, + updated_at timestamptz not null default now(), + unique(user_id, trick_id) +); + +-- Trick suggestions: sparse proposed edits to approved tricks. +-- Only changed fields are stored; null means "no change to this field". +-- Approved rows are deleted after applying the delta; rejected rows deleted outright. +create table trick_suggestions ( + id integer generated always as identity primary key, + trick_id integer not null references tricks(id) on delete cascade, + given_name text, + technical_name text, + difficulty_tier smallint check (difficulty_tier = -1 or difficulty_tier between 1 and 30), + date_performed date, + original_performer text, + prerequisite_trick_ids integer[], + base_trick_ids integer[], + description text, + tips text, + video_link text, + video_start integer, + video_end integer, + start_position_id smallint references positions(id), + end_position_id smallint references positions(id), + submitted_by integer references profiles(int_id) on delete set null, + date_submitted timestamptz not null default now() +); + +-- Tips: community-submitted general highlining tips (not trick-specific) +-- status: false = pending, true = approved +-- type: 0 = general, 1 = rigging, 2 = health +create table tips ( + id integer generated always as identity primary key, + title text not null, + header text, + body text not null, + status boolean not null default false, + type smallint not null default 0 check (type between 0 and 2), + submitted_on date not null default current_date, + submitted_by integer references profiles(int_id) on delete set null, + approved_on date, + approved_by integer references profiles(int_id) on delete set null, + last_updated date, + last_updated_by integer references profiles(int_id) on delete set null +); + +-- Trick annotations: editor-placed time-windowed text overlays for the training studio +create table trick_annotations ( + id serial primary key, + trick_id int not null references tricks(id) on delete cascade, + start_ms int not null, + end_ms int not null, + text text not null, + created_by int not null references profiles(int_id), + created_at timestamptz not null default now(), + language text not null default 'en' +); + +-- ============================================================ +-- Row Level Security +-- ============================================================ + +alter table profiles enable row level security; +alter table tricks enable row level security; +alter table user_tricks enable row level security; +alter table positions enable row level security; +alter table trick_suggestions enable row level security; +alter table tips enable row level security; +alter table trick_annotations enable row level security; + +-- Profiles +create policy "profiles_read" on profiles for select using (true); +create policy "profiles_insert" on profiles for insert with check (auth.uid() = id); +create policy "profiles_update" on profiles for update using (auth.uid() = id); + +-- Positions: anyone reads, only editors write +create policy "positions_read" on positions for select using (true); +create policy "positions_insert" on positions for insert with check ( + exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1) +); +create policy "positions_update" on positions for update using ( + exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1) +); +create policy "positions_delete" on positions for delete using ( + exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1) +); + +-- Tricks: approved tricks are public; submitter can read own; editors can read/write all +create policy "tricks_read_approved" on tricks for select using (status = 1); +create policy "tricks_read_own" on tricks for select + using (submitted_by = (select int_id from profiles where id = auth.uid())); +create policy "tricks_read_admin" on tricks for select + using (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); +create policy "tricks_insert" on tricks for insert + with check (submitted_by = (select int_id from profiles where id = auth.uid())); +create policy "tricks_update_admin" on tricks for update + using (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); +create policy "tricks_delete_admin" on tricks for delete + using (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); + +-- User tricks: users manage only their own rows +create policy "user_tricks_select" on user_tricks for select + using (user_id = (select int_id from profiles where id = auth.uid())); +create policy "user_tricks_insert" on user_tricks for insert + with check (user_id = (select int_id from profiles where id = auth.uid())); +create policy "user_tricks_update" on user_tricks for update + using (user_id = (select int_id from profiles where id = auth.uid())); +create policy "user_tricks_delete" on user_tricks for delete + using (user_id = (select int_id from profiles where id = auth.uid())); + +-- Trick suggestions +create policy "suggestions_insert" on trick_suggestions for insert + with check (submitted_by = (select int_id from profiles where id = auth.uid())); +create policy "suggestions_read_own" on trick_suggestions for select + using (submitted_by = (select int_id from profiles where id = auth.uid())); +create policy "suggestions_read_admin" on trick_suggestions for select + using (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); +create policy "suggestions_delete_admin" on trick_suggestions for delete + using (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); + +-- Tips +create policy "tips_read_approved" on tips for select using (status = true); +create policy "tips_read_own" on tips for select + using (submitted_by = (select int_id from profiles where id = auth.uid())); +create policy "tips_read_admin" on tips for select + using (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); +create policy "tips_insert" on tips for insert + with check (auth.uid() is not null and status = false); +create policy "tips_update_admin" on tips for update + using (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); +create policy "tips_delete_admin" on tips for delete + using (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); + +-- Trick annotations: anyone reads, only editors write +create policy "annotations_read" on trick_annotations for select using (true); +create policy "annotations_insert" on trick_annotations for insert + with check (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); +create policy "annotations_update" on trick_annotations for update + using (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); +create policy "annotations_delete" on trick_annotations for delete + using (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); + +-- ============================================================ +-- Grants +-- ============================================================ + +grant select on positions to anon, authenticated; +grant insert, update, delete on positions to authenticated; +grant select on tricks to anon, authenticated; +grant insert, update on tricks to authenticated; +grant select on profiles to anon, authenticated; +grant insert, update on profiles to authenticated; +grant select, insert, update, delete on user_tricks to authenticated; +grant select, insert, delete on trick_suggestions to authenticated; +grant select on tips to anon, authenticated; +grant insert on tips to authenticated; +grant update, delete on tips to authenticated; +grant select on trick_annotations to anon, authenticated; +grant insert, update, delete on trick_annotations to authenticated; +grant usage, select on sequence trick_annotations_id_seq to authenticated; + +-- ============================================================ +-- Functions +-- ============================================================ + +create or replace function touch_updated_at() +returns trigger language plpgsql as $$ +begin new.updated_at = now(); return new; end; +$$; + +create trigger user_tricks_updated_at + before update on user_tricks + for each row execute function touch_updated_at(); + +create or replace function get_trick_vote_stats(p_trick_id integer) +returns json language sql security definer as $$ + select json_build_object( + 'difficulty_votes', coalesce(( + select json_object_agg(difficulty_vote::text, cnt) + from ( + select difficulty_vote, count(*) as cnt + from user_tricks + where trick_id = p_trick_id and difficulty_vote is not null + group by difficulty_vote + ) t + ), '{}'), + 'leash_positions', coalesce(( + select json_object_agg(leash_position::text, cnt) + from ( + select leash_position, count(*) as cnt + from user_tricks + where trick_id = p_trick_id and leash_position is not null + group by leash_position + ) t + ), '{}') + ); +$$; + +grant execute on function get_trick_vote_stats(integer) to anon, authenticated; + +-- ============================================================ +-- Trigger: auto-create profile on sign-up +-- ============================================================ + +create or replace function handle_new_user() +returns trigger language plpgsql security definer set search_path = public as $$ +begin + insert into public.profiles (id, username, flags) + values (new.id, new.raw_user_meta_data->>'username', 0); + return new; +end; +$$; + +create trigger on_auth_user_created + after insert on auth.users + for each row execute function handle_new_user(); + +-- ============================================================ +-- Trigger: clean up array references when a trick is deleted +-- ============================================================ + +create or replace function remove_deleted_trick_refs() +returns trigger language plpgsql as $$ +begin + update tricks + set prerequisite_trick_ids = array_remove(prerequisite_trick_ids, old.id), + base_trick_ids = array_remove(base_trick_ids, old.id) + where old.id = any(prerequisite_trick_ids) + or old.id = any(base_trick_ids); + return old; +end; +$$; + +create trigger on_trick_deleted + before delete on tricks + for each row execute function remove_deleted_trick_refs(); diff --git a/supabase/seed_users.sql b/supabase/seed_users.sql new file mode 100644 index 0000000..98bb2a8 --- /dev/null +++ b/supabase/seed_users.sql @@ -0,0 +1,51 @@ +-- Local-only auth users, seeded on `supabase db reset`. Plain SQL (runs via +-- the CLI's SQL driver, so no psql meta-commands). Passwords are bcrypt- +-- hashed via pgcrypto the same way GoTrue stores them. Inserting into +-- auth.users fires handle_new_user(), which creates the matching profiles +-- row (username taken from raw_user_meta_data). +-- +-- Throwaway dev credentials - never use in prod. Both use password123. +-- dev@local.test normal user +-- admin@local.test editor/admin (flags bit 0 set below) + +-- confirmation_token / recovery_token / email_change* must be '' (not +-- NULL) or GoTrue's schema scan errors with "Database error querying +-- schema" on login. +insert into auth.users ( + instance_id, id, aud, role, email, encrypted_password, + email_confirmed_at, raw_app_meta_data, raw_user_meta_data, + created_at, updated_at, + confirmation_token, recovery_token, email_change, email_change_token_new +) values + ('00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + 'authenticated', 'authenticated', 'dev@local.test', + crypt('password123', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}', '{"username":"dev"}', + now(), now(), + '', '', '', ''), + ('00000000-0000-0000-0000-000000000000', + '22222222-2222-2222-2222-222222222222', + 'authenticated', 'authenticated', 'admin@local.test', + crypt('password123', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}', '{"username":"admin"}', + now(), now(), + '', '', '', '') +on conflict (id) do nothing; + +insert into auth.identities ( + id, user_id, provider_id, identity_data, provider, created_at, updated_at +) values + (gen_random_uuid(), '11111111-1111-1111-1111-111111111111', + '11111111-1111-1111-1111-111111111111', + '{"sub":"11111111-1111-1111-1111-111111111111","email":"dev@local.test"}', + 'email', now(), now()), + (gen_random_uuid(), '22222222-2222-2222-2222-222222222222', + '22222222-2222-2222-2222-222222222222', + '{"sub":"22222222-2222-2222-2222-222222222222","email":"admin@local.test"}', + 'email', now(), now()) +on conflict do nothing; + +-- flags bit 0 = can edit tricks (admin/editor). +update profiles set flags = flags | 1 +where id = '22222222-2222-2222-2222-222222222222'; From 477f3b7bb43b0e486642cb3d6ca5ebcd5ee5f110 Mon Sep 17 00:00:00 2001 From: Sebastian Egger <74077179+bastislack@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:38:45 +0200 Subject: [PATCH 2/2] Simplify Supabase config comments Removed comments about auth-provider-specific settings and CLI regeneration. --- supabase/config.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/supabase/config.toml b/supabase/config.toml index 62fcde1..96bb0f2 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1,6 +1,4 @@ -# Local Supabase stack config, provider-agnostic. Auth-provider-specific -# settings (e.g. Cognito third-party auth) are layered on by feature -# branches, not committed here. Regenerate with `supabase init` if your CLI +# Local Supabase stack config. Regenerate with `supabase init` if your CLI # version rejects any field below. project_id = "freestyle"