From 406fcd4da721926141decf83500e464ff6b39265 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Fri, 15 May 2026 23:10:25 +0200 Subject: [PATCH 1/4] chore: add Supabase baseline migration + security docs (issues #228 #235 #236 #242 #247) - Add 20260101000000_baseline_schema.sql: full idempotent snapshot of all 15 tables, functions, trigger, grants and RLS policies so Supabase preview branches and local db reset work without errors (issue #247) - Add 20260515_rec_cache_ownership.sql: owner-scoped write policies for recommendation_cache, adding written_by column (issue #235; already live) - Make 20260514 delta idempotent: ADD COLUMN IF NOT EXISTS - Update CHANGELOG.md with [1.5.7] security + infra entries - Update CLAUDE.md: sportySync JWT auth, rec_cache written_by, prompt XML tags, Supabase preview pitfall (#247) - Update README.md: SPORTY_SYNC_API_KEY now health-endpoint-only Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 14 + CLAUDE.md | 16 +- README.md | 2 +- .../20260101000000_baseline_schema.sql | 621 ++++++++++++++++++ ...add_template_type_to_session_templates.sql | 2 +- .../20260515_rec_cache_ownership.sql | 28 +- 6 files changed, 666 insertions(+), 17 deletions(-) create mode 100644 supabase/migrations/20260101000000_baseline_schema.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a11489..802aafe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to Workout Lens are documented here. +## [1.5.7] — 2026-05-15 + +### Security +- **Sporty-sync HTTP trigger now requires Supabase JWT (issue #228)** — `POST /api/sporty-sync` previously relied on a shared `SPORTY_SYNC_API_KEY` secret that was also used by the health endpoint; any authenticated app user can now trigger a manual sync, and the health endpoint (`GET /api/sporty-health`) is the only remaining consumer of the API key. `Home.jsx` updated to send `X-Supabase-Token` instead. +- **Recommendation cache owner-scoped writes (issue #235)** — `recommendation_cache` gains a `written_by uuid NOT NULL DEFAULT auth.uid()` column. The open UPDATE policy (`auth.role() = 'authenticated'`) is replaced by an owner-only policy (`written_by = auth.uid()`), preventing one user from overwriting another user's cached recommendation. INSERT policy tightened to the same constraint. +- **Prompt injection protection in `buildMuscleInferencePrompt` (issue #236)** — exercise names containing `<>` characters could inject XML tags into the Claude prompt. Names are now sanitised (angle brackets stripped) and wrapped in `…` XML tags before insertion, following Anthropic's recommended boundary pattern. Three regression tests added. +- **`handle_new_user()` RPC access revoked (issue #242)** — the auth trigger function was callable directly via `/rest/v1/rpc/handle_new_user` by any `anon` or `authenticated` role. Privilege revoked; the function now only fires internally via the `on_auth_user_created` trigger. +- **`SET search_path = public` on all stored functions (issue #242)** — `replace_template_exercises`, `save_session`, and `update_session` lacked a pinned `search_path`. A privileged attacker could shadow public-schema tables with malicious objects in a different schema. Fixed with `ALTER FUNCTION … SET search_path = public`. +- **`SPORTY_SYNC_API_KEY` removed from gitignore allowlist (issue #242)** — the key was previously explicitly tracked in `app/api/local.settings.json.example`; file updated to reflect health-only scope. +- **Content-Security-Policy tightened (issue #242)** — `script-src` and `connect-src` directives in `staticwebapp.config.json` reduced to the minimum required origins. + +### Developer / Infrastructure +- **Supabase preview branches now work (issue #247)** — `supabase/migrations/` previously contained only delta migrations (ALTER TABLE / REVOKE). Supabase preview creates a fresh database and runs every file in order; the earliest delta (`20260514_add_template_type_to_session_templates.sql`) failed immediately because `session_templates` didn't exist. Fixed by adding `20260101000000_baseline_schema.sql` — a comprehensive idempotent snapshot of all 15 tables, functions, the `on_auth_user_created` trigger, grants, and RLS policies. All `CREATE` statements use `IF NOT EXISTS` / `CREATE OR REPLACE` so the file is also safe to apply to the live database as a no-op. The two existing delta migrations were updated to use `ADD COLUMN IF NOT EXISTS` / `DROP POLICY IF EXISTS` for the same reason. + ## [1.5.6] — 2026-05-15 ### Developer / Infrastructure diff --git a/CLAUDE.md b/CLAUDE.md index c1c1b3a..d0ef69a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -244,7 +244,8 @@ Shared lookup table — no `user_id` column. Any authenticated user whose filter recommendation_cache cache_key text PRIMARY KEY, -- v{RECS_PROMPT_VERSION}_{periodDays}_{sessionCount}_{trainedIds}_{untrainedIds} recs jsonb NOT NULL, -- array of { name, primary[], secondary[], tip } - fetched_at timestamptz NOT NULL DEFAULT now() + fetched_at timestamptz NOT NULL DEFAULT now(), + written_by uuid NOT NULL DEFAULT auth.uid() REFERENCES profiles(id) -- owner (issue #235) ``` `cache_key` encodes everything Claude sees: prompt version, period, session count, and sorted trained/untrained muscle ID lists. Changing any of these produces a different key → natural cache miss → fresh fetch. @@ -262,7 +263,7 @@ recommendation_cache - **Shared muscle/SVG module:** `app/src/lib/bodymap.jsx` exports `MUSCLES`, `SHAPES`, `EX_DB`, color constants (`PRIMARY_FILL`, `PRIMARY_HOVER`, `PRIMARY_STROKE`, heat vars), `calcMuscles`, `BodySVG`, `HeatmapBodySVG` (accepts `onHover(id|null)` and `hovered` props — when `onHover` is set the internal tooltip is suppressed), and `useIsMobile`. Do not duplicate these in component files. - **Shared hooks:** `app/src/lib/hooks.js` — exports `useDebouncedSearch(delayMs?)` (returns `{ search, setSearch, debouncedSearch }`; debounced value is raw — trim/lowercase at use-site) and `useFetch(fn, deps?)` (returns `{ data, loading, error, setData }`; `setData` allows optimistic updates; use only for data fetched once per mount without mutation-driven refetches). Import from here; do not copy the timer pattern locally. - **Shared utilities:** `app/src/lib/utils.js` — exports `toBase64`, `getMediaType`, `buildMuscleMapFromExercises` (with EX_DB fallback, for confirm/edit steps), `buildMuscleMapFromSession` (reads saved DB session for History read mode), `buildRecMuscleMap` (for recommendation body maps), `isInvalidNum` (validates sets/reps as integers 1–99), `callClaude(body)` (authenticated fetch to `/api/claude` — returns raw `Response`; always call `await res.json()` to read the body), `inferMusclesFromName(name)` (calls Claude Sonnet text API to infer muscle IDs for a single exercise name — returns `{ primary, secondary }` or `null`; handles markdown code fences defensively), `extractMuscles(session)` (splits `muscle_activations` into primary/secondary Sets, removes primary from secondary), `toWeekIso(date)` (Date → `"2026-W19"` ISO week string), `weekIsoToMonday(weekIso)` (`"2026-W19"` → Monday `Date`), `isoWeekMonday(date)` (Date → Monday `Date` of that ISO week, local time), `toIsoDate(date)` (Date → `"yyyy-MM-dd"` string using local time getters — replaces `date-fns` `format`), `getIntlLocale()` (maps `i18n.language` to the IETF tag `Intl` expects, e.g. `"nb" → "no"`). Do not redefine these locally in component files. -- **Shared Claude config:** `app/src/lib/prompts.js` — exports `CLAUDE_MODEL_VISION` (sonnet-4-6, for image analysis), `CLAUDE_MODEL_TEXT` (sonnet-4-6, for recommendations), `RECS_PROMPT_VERSION` (integer — bump whenever `buildPeriodRecommendPrompt` or the model changes; old cache entries are swept by the weekly cleanup job; also keep `RECS_PROMPT_VERSION` in `app/api/recsCacheCleanup.js` in sync), `ANALYZE_PROMPT`, `buildRecommendPrompt(trained, untrained)`, `buildPeriodRecommendPrompt(periodDays, sessionCount, trainedLabels, untrainedLabels)`, `buildMuscleInferencePrompt(name)` (cheap text-only call for single-exercise muscle inference). All model IDs and prompt text live here; update in one place. +- **Shared Claude config:** `app/src/lib/prompts.js` — exports `CLAUDE_MODEL_VISION` (sonnet-4-6, for image analysis), `CLAUDE_MODEL_TEXT` (sonnet-4-6, for recommendations), `RECS_PROMPT_VERSION` (integer — bump whenever `buildPeriodRecommendPrompt` or the model changes; old cache entries are swept by the weekly cleanup job; also keep `RECS_PROMPT_VERSION` in `app/api/recsCacheCleanup.js` in sync), `ANALYZE_PROMPT`, `buildRecommendPrompt(trained, untrained)`, `buildPeriodRecommendPrompt(periodDays, sessionCount, trainedLabels, untrainedLabels)`, `buildMuscleInferencePrompt(name)` (cheap text-only call for single-exercise muscle inference — strips `<>` from the name and wraps it in `…` XML tags per Anthropic's prompt injection boundary pattern; 3 regression tests in `prompts.test.js`). All model IDs and prompt text live here; update in one place. - Claude returns muscle IDs directly in JSON — local keyword matching (EX_DB) was abandoned because Norwegian abbreviations and whiteboard variants didn't match reliably. EX_DB is kept only as fallback for manually added exercises. - SVG body uses `BODY_PATH` (bezier curves, viewBox `0 0 160 360`) — improved silhouette with curved shoulders, arms, waist and hips. Still simplified, not anatomically precise. `SHAPES` entries are either ellipses (`{ cx, cy, rx, ry }`) or SVG paths (`{ d }`); the render loop handles both. Key muscles with path shapes: `traps` (trapezoid with neck notch), `lats` (wing paths). `BodySVG` renders primary muscles as solid green glow, secondary as diagonal blue stripes (``). - `useIsMobile(breakpoint=500)` — exported hook from `bodymap.jsx`. Below breakpoint: single body view with Front/Bak toggle. Above: side-by-side. Consumed via `BodyPanel` — do not use directly in page components. @@ -281,7 +282,7 @@ recommendation_cache - Anthropic API calls go through `app/api/claude.js` — Azure Function v4 model, browser hits `/api/claude` - **Azure Functions entry point:** `app/api/index.js` imports all function files (`claude.js`, `sportySync.js`, `recsCacheCleanup.js`). `package.json#main` points to `index.js`. Azure Functions v4 only loads the single file referenced in `main` — add new function files here or they will never be registered. **The API `package.json` only has `@azure/functions` as a dependency — use raw `fetch` to the Supabase REST API (as `sportySync.js` does), never `import { createClient } from '@supabase/supabase-js'` in API files.** - **Sporty.no sync:** `app/api/sportySync.js` — timer trigger at 04:00, 11:00, and 14:00 UTC upserts today's sessions from `https://sporty.no/api/v1/businessunits/8/groupactivities` into `gym_calendar` by `sporty_id`. Class names are passed through `normalizeName()` (imported from `sportyUtils.js`) before storage, which strips any quoted annotation (e.g. `"SVART TRØYE"`) — handles both straight and curly double quotes generically. -- **`app/api/sportyUtils.js`** — pure utility with no Azure SDK dependency. Exports `normalizeName(name)`. Kept separate from `sportySync.js` so it can be unit-tested without mocking the Azure Functions runtime. Business unit `8` is hardcoded — intentional for now (single-gym product); if extended to multiple gyms, this must become an env var or DB config. HTTP trigger `POST /api/sporty-sync` available for manual testing; accepts optional JSON body `{ "shiftDays": -7 }` to offset all timestamps by N days (useful for backfilling historical gym calendar data without re-running the live API). `GET /api/sporty-health` returns DB row counts (total rows, earliest/latest row timestamps, today's session count — no session list) — requires `x-api-key: ` header (same key as the sync endpoint). Requires `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, and `SPORTY_SYNC_API_KEY` as Azure app settings (service role needed because the timer has no auth user). **The timer registration is guarded by `process.env.AZURE_FUNCTIONS_ENVIRONMENT === 'Production'`** — it is skipped entirely in local dev because the SWA CLI emulator only supports HTTP triggers. +- **`app/api/sportyUtils.js`** — pure utility with no Azure SDK dependency. Exports `normalizeName(name)`. Kept separate from `sportySync.js` so it can be unit-tested without mocking the Azure Functions runtime. Business unit `8` is hardcoded — intentional for now (single-gym product); if extended to multiple gyms, this must become an env var or DB config. HTTP trigger `POST /api/sporty-sync` requires a valid Supabase JWT in `X-Supabase-Token` header (verified via `verifySupabaseJwt`); accepts optional JSON body `{ "shiftDays": -7 }` to offset all timestamps by N days (useful for backfilling historical gym calendar data). `GET /api/sporty-health` returns DB row counts (total rows, earliest/latest row timestamps, today's session count — no session list) — still requires `x-api-key: ` header (external monitoring tool, not a frontend call). Requires `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_ANON_KEY`, and `SPORTY_SYNC_API_KEY` as Azure app settings (service role needed because the timer has no auth user; anon key used by `verifySupabaseJwt`). **The timer registration is guarded by `process.env.AZURE_FUNCTIONS_ENVIRONMENT === 'Production'`** — it is skipped entirely in local dev because the SWA CLI emulator only supports HTTP triggers. - **Rec cache cleanup:** `app/api/recsCacheCleanup.js` — timer trigger every Sunday at 03:00 UTC. Two DELETE passes: (1) entries older than 7 days (TTL — forces fresh recommendations; also catches version-orphaned entries that were never refreshed); (2) entries whose `cache_key` does not start with `v${RECS_PROMPT_VERSION}_` (sweeps orphans immediately after a prompt/model version bump). Uses raw `fetch` to Supabase REST API (no SDK). Requires `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY`. - **Claude API proxy:** `app/api/claude.js` verifies incoming Supabase JWTs via `GET /auth/v1/user`. Requires `ANTHROPIC_API_KEY`, `SUPABASE_URL`, and `SUPABASE_ANON_KEY` as Azure app settings. Use `SUPABASE_ANON_KEY` (no `VITE_` prefix) — the `VITE_` prefix is Vite build-time only and is invisible to the Azure Functions runtime. - **CI/CD build split:** the frontend is pre-built in the GitHub Actions runner (`npm ci && npm run build` with `VITE_*` in `env:`), then the Azure SWA action uploads `app/dist/` directly (`app_location: "app/dist"`). This bypasses Oryx for the frontend — Oryx strips `VITE_*` env vars before spawning Vite and they never reach the bundle. Oryx still handles the API (`app/api`). `vite.config.js` has a build-time assertion that fails immediately if the required vars are missing. @@ -443,6 +444,15 @@ Symptom: uploading an iPhone 17 Pro photo failed with `Serverfeil (400): image e **Never revert to `img.src = dataUrl` for large images** — iOS will silently zero out naturalWidth/Height. Do not use `b64.length * 0.75` to compare against Anthropic's limit — compare `b64.length` directly. +### Issue #247 — Supabase preview branches fail with "relation does not exist" (resolved 2026-05-15) +Symptom: every PR showed a failing "Supabase Preview" check with `ERROR: relation 'session_templates' does not exist (SQLSTATE 42P01)` at the first delta migration (`20260514_add_template_type_to_session_templates.sql`). + +**Root cause — no baseline migration:** Supabase preview branches create a fresh, empty database and then apply every file in `supabase/migrations/` in filename order. The repo only had delta migrations (ALTER TABLE, REVOKE, etc.) — no initial CREATE TABLE statements. On a fresh DB, `ALTER TABLE session_templates ADD COLUMN template_type` fails immediately because the table doesn't exist. + +**Fix:** Added `supabase/migrations/20260101000000_baseline_schema.sql` — a comprehensive snapshot of all 15 tables, 3 stored functions, the `on_auth_user_created` trigger, all grants, and all RLS policies. Every statement uses `IF NOT EXISTS` / `CREATE OR REPLACE` so the file is safe to apply to the live production database as a no-op. Updated the two existing delta migrations to use `ADD COLUMN IF NOT EXISTS` and `DROP POLICY IF EXISTS` for the same idempotency guarantee. + +**Pattern:** Every future migration that adds a table or policy must have a corresponding entry in the baseline (or be added via a new delta migration with `IF NOT EXISTS`). Delta migrations that run after the baseline must not assume a specific prior state — they must be idempotent. When adding a new table in a migration, also add `CREATE TABLE IF NOT EXISTS` for it to the baseline, and reference the delta in the baseline's comment block at the top. + ### Issue #173 — fetchLastSession returning null (resolved 2026-05-14) Symptom: Home → "Siste økt" showed "Ingen økter logget ennå" even though sessions existed in the DB and the weekly strip showed the correct session count. diff --git a/README.md b/README.md index 4b8c1ec..8962f9c 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ Live URL: `https://workout.umulig.org` | `SUPABASE_URL` | Used by the Claude proxy (JWT verification) and sporty.no sync function | | `VITE_SUPABASE_ANON_KEY` | Used by the Claude proxy to verify Supabase JWTs — same value as the GitHub Actions secret | | `SUPABASE_SERVICE_ROLE_KEY` | Used by the sporty.no sync function (bypasses RLS — timer has no auth user) | -| `SPORTY_SYNC_API_KEY` | Required `x-api-key` header value for `POST /api/sporty-sync` — any secret string; endpoint returns 401 without it | +| `SPORTY_SYNC_API_KEY` | Required `x-api-key` header for `GET /api/sporty-health` (external monitoring check) — `POST /api/sporty-sync` now uses Supabase JWT auth | > **Note:** The frontend is built in the GitHub Actions runner (not by Oryx inside Azure SWA's Docker container). Oryx strips `VITE_*` env vars before spawning Vite, so they would never reach the bundle if built there. The workflow pre-builds `app/dist/` and the Azure SWA action uploads it directly via `app_location: "app/dist"`. Do not revert this. diff --git a/supabase/migrations/20260101000000_baseline_schema.sql b/supabase/migrations/20260101000000_baseline_schema.sql new file mode 100644 index 0000000..cd3da66 --- /dev/null +++ b/supabase/migrations/20260101000000_baseline_schema.sql @@ -0,0 +1,621 @@ +-- Baseline schema snapshot for Workout Lens. +-- +-- This migration was added retroactively to allow Supabase preview branches and +-- local `supabase db reset` to work. All previous schema changes were applied +-- directly to production without migration files. +-- +-- Every statement uses IF NOT EXISTS / CREATE OR REPLACE so the file is safe to +-- apply to the live database as a no-op. +-- +-- Delta migrations that run AFTER this file: +-- 20260514_add_template_type_to_session_templates.sql +-- 20260515_security_quick_wins.sql +-- 20260515_rec_cache_ownership.sql +-- This baseline therefore does NOT include: session_templates.template_type, +-- recommendation_cache.written_by, or the owner-scoped rec-cache policies. +-- Those are introduced by the delta migrations above. + +-- ── Extensions ──────────────────────────────────────────────────────────────── +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA extensions; +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- ── profiles ────────────────────────────────────────────────────────────────── +-- Mirror of auth.users; created automatically by handle_new_user() trigger. +CREATE TABLE IF NOT EXISTS public.profiles ( + id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + full_name text, + role text NOT NULL DEFAULT 'trainer' + CHECK (role IN ('admin', 'trainer')), + created_at timestamptz NOT NULL DEFAULT now(), + display_name text CHECK (char_length(display_name) <= 50) +); + +GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE + ON public.profiles TO anon, authenticated, service_role; +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "Brukere ser sin egen profil" + ON public.profiles FOR SELECT + USING (auth.uid() = id); + +CREATE POLICY IF NOT EXISTS "Brukere oppdaterer sin egen profil" + ON public.profiles FOR UPDATE + USING (auth.uid() = id); + +CREATE POLICY IF NOT EXISTS "Same-gym users can read profiles" + ON public.profiles FOR SELECT + USING (EXISTS ( + SELECT 1 FROM user_gyms ug1 + JOIN user_gyms ug2 ON ug1.sporty_business_unit_id = ug2.sporty_business_unit_id + WHERE ug1.user_id = auth.uid() AND ug2.user_id = profiles.id + )); + +-- ── training_groups ─────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.training_groups ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + trainer_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + name text NOT NULL, + description text, + created_at timestamptz NOT NULL DEFAULT now() +); + +GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE + ON public.training_groups TO anon, authenticated, service_role; +ALTER TABLE public.training_groups ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "Trenere ser egne grupper" + ON public.training_groups FOR ALL + USING (trainer_id = auth.uid()); + +-- ── gym_calendar ────────────────────────────────────────────────────────────── +-- Gym class schedule synced from sporty.no by sportySync.js (3× daily). +CREATE TABLE IF NOT EXISTS public.gym_calendar ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + sporty_id integer NOT NULL UNIQUE, + name text NOT NULL, + start_time timestamptz NOT NULL, + end_time timestamptz NOT NULL, + instructor text, + cancelled boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now() +); + +GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE + ON public.gym_calendar TO anon, authenticated, service_role; +ALTER TABLE public.gym_calendar ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "Authenticated users can read gym_calendar" + ON public.gym_calendar FOR SELECT + TO authenticated + USING (true); + +-- ── user_gyms ───────────────────────────────────────────────────────────────── +-- Links users to a Sporty business unit (gym). One row per user-gym pair. +CREATE TABLE IF NOT EXISTS public.user_gyms ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + sporty_business_unit_id integer NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (user_id, sporty_business_unit_id) +); + +GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE + ON public.user_gyms TO anon, authenticated, service_role; +ALTER TABLE public.user_gyms ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "user_gyms self select" + ON public.user_gyms FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY IF NOT EXISTS "user_gyms self insert" + ON public.user_gyms FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY IF NOT EXISTS "user_gyms self delete" + ON public.user_gyms FOR DELETE + USING (auth.uid() = user_id); + +-- ── sessions ────────────────────────────────────────────────────────────────── +-- One logged workout per row. +CREATE TABLE IF NOT EXISTS public.sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + trainer_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + training_group_id uuid REFERENCES public.training_groups(id) ON DELETE SET NULL, + session_date date NOT NULL DEFAULT CURRENT_DATE, + image_url text, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + gym_calendar_id uuid UNIQUE REFERENCES public.gym_calendar(id), + visibility text NOT NULL DEFAULT 'shared' + CHECK (visibility IN ('shared', 'private')) +); + +GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE + ON public.sessions TO anon, authenticated, service_role; +ALTER TABLE public.sessions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "Trenere ser egne økter" + ON public.sessions FOR ALL + USING (trainer_id = auth.uid()); + +CREATE POLICY IF NOT EXISTS "Users can manage their own sessions" + ON public.sessions FOR ALL + TO authenticated + USING (auth.uid() = trainer_id) + WITH CHECK (auth.uid() = trainer_id); + +CREATE POLICY IF NOT EXISTS "Same-gym users can read sessions" + ON public.sessions FOR SELECT + USING (EXISTS ( + SELECT 1 FROM user_gyms ug1 + JOIN user_gyms ug2 ON ug1.sporty_business_unit_id = ug2.sporty_business_unit_id + WHERE ug1.user_id = auth.uid() AND ug2.user_id = sessions.trainer_id + )); + +-- ── session_exercises ───────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.session_exercises ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + session_id uuid NOT NULL REFERENCES public.sessions(id) ON DELETE CASCADE, + name text NOT NULL, + standard_name text, + sets integer, + reps integer, + position integer, + created_at timestamptz NOT NULL DEFAULT now() +); + +GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE + ON public.session_exercises TO anon, authenticated, service_role; +ALTER TABLE public.session_exercises ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "Tilgang via session" + ON public.session_exercises FOR ALL + USING (EXISTS ( + SELECT 1 FROM sessions + WHERE sessions.id = session_exercises.session_id + AND sessions.trainer_id = auth.uid() + )); + +CREATE POLICY IF NOT EXISTS "Users can manage exercises in their sessions" + ON public.session_exercises FOR ALL + TO authenticated + USING (EXISTS ( + SELECT 1 FROM sessions + WHERE sessions.id = session_exercises.session_id + AND sessions.trainer_id = auth.uid() + )); + +-- ── muscle_activations ──────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.muscle_activations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + session_exercise_id uuid NOT NULL REFERENCES public.session_exercises(id) ON DELETE CASCADE, + muscle_id text NOT NULL, + activation_type text NOT NULL CHECK (activation_type IN ('primary', 'secondary')), + created_at timestamptz NOT NULL DEFAULT now() +); + +GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE + ON public.muscle_activations TO anon, authenticated, service_role; +ALTER TABLE public.muscle_activations ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "Tilgang via session_exercise" + ON public.muscle_activations FOR ALL + USING (EXISTS ( + SELECT 1 FROM session_exercises se + JOIN sessions s ON s.id = se.session_id + WHERE se.id = muscle_activations.session_exercise_id + AND s.trainer_id = auth.uid() + )); + +CREATE POLICY IF NOT EXISTS "Users can manage activations in their sessions" + ON public.muscle_activations FOR ALL + TO authenticated + USING (EXISTS ( + SELECT 1 FROM session_exercises + JOIN sessions ON sessions.id = session_exercises.session_id + WHERE session_exercises.id = muscle_activations.session_exercise_id + AND sessions.trainer_id = auth.uid() + )); + +-- ── exercise_library ────────────────────────────────────────────────────────── +-- Gym-wide named exercises with default muscle maps. FK to profiles (not auth.users) +-- so PostgREST can traverse the profiles!user_id(display_name) join. +CREATE TABLE IF NOT EXISTS public.exercise_library ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + name text NOT NULL, + primary_muscles text[] NOT NULL DEFAULT '{}', + secondary_muscles text[] NOT NULL DEFAULT '{}', + default_sets text, + default_reps text, + created_at timestamptz DEFAULT now() +); + +GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE + ON public.exercise_library TO anon, authenticated, service_role; +ALTER TABLE public.exercise_library ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "Gym members select exercises" + ON public.exercise_library FOR SELECT + USING (EXISTS ( + SELECT 1 FROM user_gyms ug1 + JOIN user_gyms ug2 ON ug2.sporty_business_unit_id = ug1.sporty_business_unit_id + WHERE ug1.user_id = auth.uid() AND ug2.user_id = exercise_library.user_id + )); + +CREATE POLICY IF NOT EXISTS "Gym members insert exercises" + ON public.exercise_library FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY IF NOT EXISTS "Gym members update exercises" + ON public.exercise_library FOR UPDATE + USING (EXISTS ( + SELECT 1 FROM user_gyms ug1 + JOIN user_gyms ug2 ON ug2.sporty_business_unit_id = ug1.sporty_business_unit_id + WHERE ug1.user_id = auth.uid() AND ug2.user_id = exercise_library.user_id + )) + WITH CHECK (EXISTS ( + SELECT 1 FROM user_gyms ug1 + JOIN user_gyms ug2 ON ug2.sporty_business_unit_id = ug1.sporty_business_unit_id + WHERE ug1.user_id = auth.uid() AND ug2.user_id = exercise_library.user_id + )); + +CREATE POLICY IF NOT EXISTS "Gym members delete exercises" + ON public.exercise_library FOR DELETE + USING (EXISTS ( + SELECT 1 FROM user_gyms ug1 + JOIN user_gyms ug2 ON ug2.sporty_business_unit_id = ug1.sporty_business_unit_id + WHERE ug1.user_id = auth.uid() AND ug2.user_id = exercise_library.user_id + )); + +-- ── session_templates ───────────────────────────────────────────────────────── +-- Named, reusable workout skeletons. FK to profiles (not auth.users) — same +-- reason as exercise_library (PostgREST join traversal). +-- NOTE: template_type column is added by migration 20260514_add_template_type. +CREATE TABLE IF NOT EXISTS public.session_templates ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + name text NOT NULL, + sort_order integer DEFAULT 0, + used_at timestamptz, + created_at timestamptz DEFAULT now() +); + +GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE + ON public.session_templates TO anon, authenticated, service_role; +ALTER TABLE public.session_templates ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "Gym members select templates" + ON public.session_templates FOR SELECT + USING (EXISTS ( + SELECT 1 FROM user_gyms ug1 + JOIN user_gyms ug2 ON ug2.sporty_business_unit_id = ug1.sporty_business_unit_id + WHERE ug1.user_id = auth.uid() AND ug2.user_id = session_templates.user_id + )); + +CREATE POLICY IF NOT EXISTS "Gym members insert templates" + ON public.session_templates FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY IF NOT EXISTS "Gym members update templates" + ON public.session_templates FOR UPDATE + USING (EXISTS ( + SELECT 1 FROM user_gyms ug1 + JOIN user_gyms ug2 ON ug2.sporty_business_unit_id = ug1.sporty_business_unit_id + WHERE ug1.user_id = auth.uid() AND ug2.user_id = session_templates.user_id + )) + WITH CHECK (EXISTS ( + SELECT 1 FROM user_gyms ug1 + JOIN user_gyms ug2 ON ug2.sporty_business_unit_id = ug1.sporty_business_unit_id + WHERE ug1.user_id = auth.uid() AND ug2.user_id = session_templates.user_id + )); + +CREATE POLICY IF NOT EXISTS "Gym members delete templates" + ON public.session_templates FOR DELETE + USING (EXISTS ( + SELECT 1 FROM user_gyms ug1 + JOIN user_gyms ug2 ON ug2.sporty_business_unit_id = ug1.sporty_business_unit_id + WHERE ug1.user_id = auth.uid() AND ug2.user_id = session_templates.user_id + )); + +-- ── session_template_exercises ──────────────────────────────────────────────── +-- Ordered exercise slots within a template. Name + muscles are a denormalised +-- snapshot — renaming the library source does not affect existing templates. +CREATE TABLE IF NOT EXISTS public.session_template_exercises ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + template_id uuid NOT NULL REFERENCES public.session_templates(id) ON DELETE CASCADE, + library_exercise_id uuid REFERENCES public.exercise_library(id) ON DELETE SET NULL, + name text NOT NULL, + primary_muscles text[] NOT NULL DEFAULT '{}', + secondary_muscles text[] NOT NULL DEFAULT '{}', + sets text, + reps text, + sort_order integer DEFAULT 0 +); + +GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE + ON public.session_template_exercises TO anon, authenticated, service_role; +ALTER TABLE public.session_template_exercises ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "Gym members access template exercises" + ON public.session_template_exercises FOR ALL + USING (EXISTS ( + SELECT 1 FROM session_templates st + JOIN user_gyms ug1 ON ug1.user_id = auth.uid() + JOIN user_gyms ug2 ON ug2.sporty_business_unit_id = ug1.sporty_business_unit_id + WHERE st.id = session_template_exercises.template_id AND ug2.user_id = st.user_id + )) + WITH CHECK (EXISTS ( + SELECT 1 FROM session_templates st + JOIN user_gyms ug1 ON ug1.user_id = auth.uid() + JOIN user_gyms ug2 ON ug2.sporty_business_unit_id = ug1.sporty_business_unit_id + WHERE st.id = session_template_exercises.template_id AND ug2.user_id = st.user_id + )); + +-- ── roles ───────────────────────────────────────────────────────────────────── +-- Instructor tenure. Active = valid_from <= today AND (valid_to IS NULL OR valid_to >= today). +CREATE TABLE IF NOT EXISTS public.roles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + sporty_business_unit_id integer NOT NULL, + name text NOT NULL DEFAULT 'instruktor', + title text, + valid_from date NOT NULL DEFAULT CURRENT_DATE, + valid_to date, + created_at timestamptz NOT NULL DEFAULT now() +); + +GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE + ON public.roles TO anon, authenticated, service_role; +ALTER TABLE public.roles ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "users see own roles" + ON public.roles FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY IF NOT EXISTS "users manage own roles" + ON public.roles FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- ── recommendation_cache ────────────────────────────────────────────────────── +-- Shared cache of Claude-generated exercise recommendations. +-- written_by column and owner-scoped policies are added by 20260515_rec_cache_ownership. +CREATE TABLE IF NOT EXISTS public.recommendation_cache ( + cache_key text PRIMARY KEY, + recs jsonb NOT NULL, + fetched_at timestamptz NOT NULL DEFAULT now() +); + +GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE + ON public.recommendation_cache TO anon, authenticated, service_role; +ALTER TABLE public.recommendation_cache ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "Authenticated users can read rec cache" + ON public.recommendation_cache FOR SELECT + USING (auth.role() = 'authenticated'); + +-- Pre-ownership INSERT and UPDATE policies; replaced by 20260515_rec_cache_ownership. +CREATE POLICY IF NOT EXISTS "Authenticated users can write rec cache" + ON public.recommendation_cache FOR INSERT + TO authenticated + WITH CHECK (auth.role() = 'authenticated'); + +CREATE POLICY IF NOT EXISTS "Authenticated users can update rec cache" + ON public.recommendation_cache FOR UPDATE + TO authenticated + USING (auth.role() = 'authenticated'); + +-- ── week_plans ──────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.week_plans ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id), + week_iso text NOT NULL, + created_at timestamptz DEFAULT now(), + UNIQUE (user_id, week_iso) +); + +GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE + ON public.week_plans TO anon, authenticated, service_role; +ALTER TABLE public.week_plans ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "Users manage own week_plans" + ON public.week_plans FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- ── week_plan_days ──────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.week_plan_days ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + plan_id uuid NOT NULL REFERENCES public.week_plans(id) ON DELETE CASCADE, + day_of_week integer NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 7), + template_id uuid REFERENCES public.session_templates(id) ON DELETE SET NULL, + sort_order integer DEFAULT 0 +); + +GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE + ON public.week_plan_days TO anon, authenticated, service_role; +ALTER TABLE public.week_plan_days ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "Users manage own week_plan_days" + ON public.week_plan_days FOR ALL + USING (EXISTS ( + SELECT 1 FROM week_plans wp + WHERE wp.id = week_plan_days.plan_id AND wp.user_id = auth.uid() + )) + WITH CHECK (EXISTS ( + SELECT 1 FROM week_plans wp + WHERE wp.id = week_plan_days.plan_id AND wp.user_id = auth.uid() + )); + +-- ── paperbot_model_snapshots ────────────────────────────────────────────────── +-- Stray table not owned by this application; service_role access only. +CREATE TABLE IF NOT EXISTS public.paperbot_model_snapshots ( + model_id text PRIMARY KEY, + state text NOT NULL, + retirement_date text NOT NULL, + first_seen_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE + ON public.paperbot_model_snapshots TO service_role; +ALTER TABLE public.paperbot_model_snapshots ENABLE ROW LEVEL SECURITY; + +-- ── Stored functions ────────────────────────────────────────────────────────── + +-- Auto-creates a profiles row on new auth.users sign-up. +-- SECURITY DEFINER so it can write to profiles without the new user existing yet. +-- search_path fixed to prevent schema injection. +CREATE OR REPLACE FUNCTION public.handle_new_user() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = public +AS $$ +begin + insert into public.profiles (id, full_name) + values (new.id, new.raw_user_meta_data->>'full_name'); + return new; +end; +$$; + +-- Full delete-and-reinsert for template exercises. Canonical update path. +CREATE OR REPLACE FUNCTION public.replace_template_exercises( + p_template_id uuid, + p_exercises jsonb +) + RETURNS void + LANGUAGE plpgsql + SET search_path = public +AS $$ +BEGIN + DELETE FROM session_template_exercises WHERE template_id = p_template_id; + + INSERT INTO session_template_exercises + (template_id, library_exercise_id, name, primary_muscles, secondary_muscles, sets, reps, sort_order) + SELECT + p_template_id, + NULLIF(e.value->>'library_exercise_id', '')::uuid, + e.value->>'name', + ARRAY(SELECT jsonb_array_elements_text(COALESCE(e.value->'primary_muscles', '[]'::jsonb))), + ARRAY(SELECT jsonb_array_elements_text(COALESCE(e.value->'secondary_muscles', '[]'::jsonb))), + NULLIF(e.value->>'sets', ''), + NULLIF(e.value->>'reps', ''), + (e.value->>'sort_order')::int + FROM jsonb_array_elements(p_exercises) AS e; +END; +$$; + +-- Inserts a session and its exercises + muscle activations in a single RPC call. +CREATE OR REPLACE FUNCTION public.save_session( + p_gym_calendar_id uuid, + p_session_date date, + p_image_url text, + p_notes text, + p_training_group_id uuid, + p_exercises jsonb, + p_replace boolean DEFAULT false +) + RETURNS sessions + LANGUAGE plpgsql + SET search_path = public +AS $$ +DECLARE + v_session sessions; + v_ex_rec record; + v_ex jsonb; + v_ex_id uuid; +BEGIN + IF p_replace AND p_gym_calendar_id IS NOT NULL THEN + DELETE FROM sessions + WHERE gym_calendar_id = p_gym_calendar_id + AND trainer_id = auth.uid(); + END IF; + + INSERT INTO sessions (trainer_id, training_group_id, gym_calendar_id, session_date, image_url, notes) + VALUES (auth.uid(), p_training_group_id, p_gym_calendar_id, p_session_date, p_image_url, p_notes) + RETURNING * INTO v_session; + + FOR v_ex_rec IN SELECT value FROM jsonb_array_elements(p_exercises) + LOOP + v_ex := v_ex_rec.value; + + INSERT INTO session_exercises (session_id, name, standard_name, sets, reps, position) + VALUES ( + v_session.id, + v_ex->>'name', + NULLIF(v_ex->>'standard_name', ''), + (v_ex->>'sets')::int, + (v_ex->>'reps')::int, + (v_ex->>'position')::int + ) + RETURNING id INTO v_ex_id; + + INSERT INTO muscle_activations (session_exercise_id, muscle_id, activation_type) + SELECT v_ex_id, m.value->>'muscle_id', m.value->>'activation_type' + FROM jsonb_array_elements(COALESCE(v_ex->'activations', '[]'::jsonb)) AS m; + END LOOP; + + RETURN v_session; +END; +$$; + +-- Replaces all exercises on an existing session. +CREATE OR REPLACE FUNCTION public.update_session( + p_session_id uuid, + p_gym_calendar_id uuid, + p_exercises jsonb, + p_replace boolean DEFAULT false +) + RETURNS void + LANGUAGE plpgsql + SET search_path = public +AS $$ +DECLARE + v_ex_rec record; + v_ex jsonb; + v_ex_id uuid; +BEGIN + IF p_replace AND p_gym_calendar_id IS NOT NULL THEN + DELETE FROM sessions + WHERE gym_calendar_id = p_gym_calendar_id + AND trainer_id = auth.uid() + AND id <> p_session_id; + END IF; + + DELETE FROM session_exercises WHERE session_id = p_session_id; + + FOR v_ex_rec IN SELECT value FROM jsonb_array_elements(p_exercises) + LOOP + v_ex := v_ex_rec.value; + + INSERT INTO session_exercises (session_id, name, standard_name, sets, reps, position) + VALUES ( + p_session_id, + v_ex->>'name', + NULLIF(v_ex->>'standard_name', ''), + (v_ex->>'sets')::int, + (v_ex->>'reps')::int, + (v_ex->>'position')::int + ) + RETURNING id INTO v_ex_id; + + INSERT INTO muscle_activations (session_exercise_id, muscle_id, activation_type) + SELECT v_ex_id, m.value->>'muscle_id', m.value->>'activation_type' + FROM jsonb_array_elements(COALESCE(v_ex->'activations', '[]'::jsonb)) AS m; + END LOOP; + + UPDATE sessions + SET gym_calendar_id = p_gym_calendar_id + WHERE id = p_session_id + AND trainer_id = auth.uid(); +END; +$$; + +-- ── Auth trigger ────────────────────────────────────────────────────────────── +-- Fires after every new auth.users row to create the matching profiles row. +CREATE TRIGGER IF NOT EXISTS on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW + EXECUTE FUNCTION handle_new_user(); diff --git a/supabase/migrations/20260514_add_template_type_to_session_templates.sql b/supabase/migrations/20260514_add_template_type_to_session_templates.sql index ecfbfae..659ec27 100644 --- a/supabase/migrations/20260514_add_template_type_to_session_templates.sql +++ b/supabase/migrations/20260514_add_template_type_to_session_templates.sql @@ -1,3 +1,3 @@ -- Sprint 3: add template_type to session_templates -- Nullable, no default — existing rows keep null (treated as unset in UI). -ALTER TABLE session_templates ADD COLUMN template_type text; +ALTER TABLE session_templates ADD COLUMN IF NOT EXISTS template_type text; diff --git a/supabase/migrations/20260515_rec_cache_ownership.sql b/supabase/migrations/20260515_rec_cache_ownership.sql index fb0a098..dbaf790 100644 --- a/supabase/migrations/20260515_rec_cache_ownership.sql +++ b/supabase/migrations/20260515_rec_cache_ownership.sql @@ -1,33 +1,37 @@ --- Add written_by so each cache entry has a deterministic owner. +-- Issue #235: owner-scoped recommendation_cache policies. +-- Adds written_by column so each cache entry has a deterministic owner. -- DEFAULT auth.uid() means saveRecsCache does not need to include the column — -- PostgreSQL sets it automatically from the calling user on INSERT. +-- +-- TRUNCATE is safe: recommendation_cache is ephemeral (7-day TTL). +-- Every statement is idempotent so this file is safe to re-apply. + +-- Add owner column; no-op if already present. ALTER TABLE public.recommendation_cache - ADD COLUMN written_by uuid REFERENCES profiles(id); + ADD COLUMN IF NOT EXISTS written_by uuid REFERENCES public.profiles(id); --- Clear the cache before making the column NOT NULL. --- recommendation_cache is ephemeral (7-day TTL) — truncating is safe. +-- Empty the cache before making the column NOT NULL. TRUNCATE public.recommendation_cache; +-- Make column required and default to the calling user's UID. ALTER TABLE public.recommendation_cache ALTER COLUMN written_by SET NOT NULL, ALTER COLUMN written_by SET DEFAULT auth.uid(); -- Tighten INSERT: written_by must equal the calling user. -DROP POLICY "Authenticated users can write rec cache" ON public.recommendation_cache; +DROP POLICY IF EXISTS "Authenticated users can write rec cache" ON public.recommendation_cache; -CREATE POLICY "Authenticated users can write rec cache" - ON public.recommendation_cache - FOR INSERT +CREATE POLICY IF NOT EXISTS "Authenticated users can write rec cache" + ON public.recommendation_cache FOR INSERT TO authenticated WITH CHECK (written_by = auth.uid()); -- Tighten UPDATE: only the original writer can refresh their own entry. -- When a different user's upsert hits ON CONFLICT, the UPDATE is silently -- skipped (no error) — the existing entry stays, preventing poisoning. -DROP POLICY "Authenticated users can update rec cache" ON public.recommendation_cache; +DROP POLICY IF EXISTS "Authenticated users can update rec cache" ON public.recommendation_cache; -CREATE POLICY "Cache owner can update their entry" - ON public.recommendation_cache - FOR UPDATE +CREATE POLICY IF NOT EXISTS "Cache owner can update their entry" + ON public.recommendation_cache FOR UPDATE TO authenticated USING (written_by = auth.uid()); From 95078b491e3f615677d0634d896dd495b2f89554 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Fri, 15 May 2026 23:15:08 +0200 Subject: [PATCH 2/4] fix: use DROP+CREATE instead of CREATE POLICY IF NOT EXISTS (PG15 compat) CREATE POLICY IF NOT EXISTS is PostgreSQL 17+ only. Supabase preview branches run PG15/16. Replace every occurrence in the baseline and rec_cache_ownership migrations with DROP POLICY IF EXISTS + CREATE POLICY (idempotent on PG15+). Same fix for CREATE TRIGGER IF NOT EXISTS. Co-Authored-By: Claude Sonnet 4.6 --- .../20260101000000_baseline_schema.sql | 98 ++++++++++++------- .../20260515_rec_cache_ownership.sql | 4 +- 2 files changed, 67 insertions(+), 35 deletions(-) diff --git a/supabase/migrations/20260101000000_baseline_schema.sql b/supabase/migrations/20260101000000_baseline_schema.sql index cd3da66..980d641 100644 --- a/supabase/migrations/20260101000000_baseline_schema.sql +++ b/supabase/migrations/20260101000000_baseline_schema.sql @@ -1,4 +1,4 @@ --- Baseline schema snapshot for Workout Lens. +-- Baseline schema snapshot for Workout Lens. -- -- This migration was added retroactively to allow Supabase preview branches and -- local `supabase db reset` to work. All previous schema changes were applied @@ -34,15 +34,18 @@ GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE ON public.profiles TO anon, authenticated, service_role; ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; -CREATE POLICY IF NOT EXISTS "Brukere ser sin egen profil" +DROP POLICY IF EXISTS "Brukere ser sin egen profil" ON public.profiles; +CREATE POLICY "Brukere ser sin egen profil" ON public.profiles FOR SELECT USING (auth.uid() = id); -CREATE POLICY IF NOT EXISTS "Brukere oppdaterer sin egen profil" +DROP POLICY IF EXISTS "Brukere oppdaterer sin egen profil" ON public.profiles; +CREATE POLICY "Brukere oppdaterer sin egen profil" ON public.profiles FOR UPDATE USING (auth.uid() = id); -CREATE POLICY IF NOT EXISTS "Same-gym users can read profiles" +DROP POLICY IF EXISTS "Same-gym users can read profiles" ON public.profiles; +CREATE POLICY "Same-gym users can read profiles" ON public.profiles FOR SELECT USING (EXISTS ( SELECT 1 FROM user_gyms ug1 @@ -63,7 +66,8 @@ GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE ON public.training_groups TO anon, authenticated, service_role; ALTER TABLE public.training_groups ENABLE ROW LEVEL SECURITY; -CREATE POLICY IF NOT EXISTS "Trenere ser egne grupper" +DROP POLICY IF EXISTS "Trenere ser egne grupper" ON public.training_groups; +CREATE POLICY "Trenere ser egne grupper" ON public.training_groups FOR ALL USING (trainer_id = auth.uid()); @@ -84,7 +88,8 @@ GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE ON public.gym_calendar TO anon, authenticated, service_role; ALTER TABLE public.gym_calendar ENABLE ROW LEVEL SECURITY; -CREATE POLICY IF NOT EXISTS "Authenticated users can read gym_calendar" +DROP POLICY IF EXISTS "Authenticated users can read gym_calendar" ON public.gym_calendar; +CREATE POLICY "Authenticated users can read gym_calendar" ON public.gym_calendar FOR SELECT TO authenticated USING (true); @@ -103,15 +108,18 @@ GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE ON public.user_gyms TO anon, authenticated, service_role; ALTER TABLE public.user_gyms ENABLE ROW LEVEL SECURITY; -CREATE POLICY IF NOT EXISTS "user_gyms self select" +DROP POLICY IF EXISTS "user_gyms self select" ON public.user_gyms; +CREATE POLICY "user_gyms self select" ON public.user_gyms FOR SELECT USING (auth.uid() = user_id); -CREATE POLICY IF NOT EXISTS "user_gyms self insert" +DROP POLICY IF EXISTS "user_gyms self insert" ON public.user_gyms; +CREATE POLICY "user_gyms self insert" ON public.user_gyms FOR INSERT WITH CHECK (auth.uid() = user_id); -CREATE POLICY IF NOT EXISTS "user_gyms self delete" +DROP POLICY IF EXISTS "user_gyms self delete" ON public.user_gyms; +CREATE POLICY "user_gyms self delete" ON public.user_gyms FOR DELETE USING (auth.uid() = user_id); @@ -134,17 +142,20 @@ GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE ON public.sessions TO anon, authenticated, service_role; ALTER TABLE public.sessions ENABLE ROW LEVEL SECURITY; -CREATE POLICY IF NOT EXISTS "Trenere ser egne økter" +DROP POLICY IF EXISTS "Trenere ser egne økter" ON public.sessions; +CREATE POLICY "Trenere ser egne økter" ON public.sessions FOR ALL USING (trainer_id = auth.uid()); -CREATE POLICY IF NOT EXISTS "Users can manage their own sessions" +DROP POLICY IF EXISTS "Users can manage their own sessions" ON public.sessions; +CREATE POLICY "Users can manage their own sessions" ON public.sessions FOR ALL TO authenticated USING (auth.uid() = trainer_id) WITH CHECK (auth.uid() = trainer_id); -CREATE POLICY IF NOT EXISTS "Same-gym users can read sessions" +DROP POLICY IF EXISTS "Same-gym users can read sessions" ON public.sessions; +CREATE POLICY "Same-gym users can read sessions" ON public.sessions FOR SELECT USING (EXISTS ( SELECT 1 FROM user_gyms ug1 @@ -168,7 +179,8 @@ GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE ON public.session_exercises TO anon, authenticated, service_role; ALTER TABLE public.session_exercises ENABLE ROW LEVEL SECURITY; -CREATE POLICY IF NOT EXISTS "Tilgang via session" +DROP POLICY IF EXISTS "Tilgang via session" ON public.session_exercises; +CREATE POLICY "Tilgang via session" ON public.session_exercises FOR ALL USING (EXISTS ( SELECT 1 FROM sessions @@ -176,7 +188,8 @@ CREATE POLICY IF NOT EXISTS "Tilgang via session" AND sessions.trainer_id = auth.uid() )); -CREATE POLICY IF NOT EXISTS "Users can manage exercises in their sessions" +DROP POLICY IF EXISTS "Users can manage exercises in their sessions" ON public.session_exercises; +CREATE POLICY "Users can manage exercises in their sessions" ON public.session_exercises FOR ALL TO authenticated USING (EXISTS ( @@ -198,7 +211,8 @@ GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE ON public.muscle_activations TO anon, authenticated, service_role; ALTER TABLE public.muscle_activations ENABLE ROW LEVEL SECURITY; -CREATE POLICY IF NOT EXISTS "Tilgang via session_exercise" +DROP POLICY IF EXISTS "Tilgang via session_exercise" ON public.muscle_activations; +CREATE POLICY "Tilgang via session_exercise" ON public.muscle_activations FOR ALL USING (EXISTS ( SELECT 1 FROM session_exercises se @@ -207,7 +221,8 @@ CREATE POLICY IF NOT EXISTS "Tilgang via session_exercise" AND s.trainer_id = auth.uid() )); -CREATE POLICY IF NOT EXISTS "Users can manage activations in their sessions" +DROP POLICY IF EXISTS "Users can manage activations in their sessions" ON public.muscle_activations; +CREATE POLICY "Users can manage activations in their sessions" ON public.muscle_activations FOR ALL TO authenticated USING (EXISTS ( @@ -235,7 +250,8 @@ GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE ON public.exercise_library TO anon, authenticated, service_role; ALTER TABLE public.exercise_library ENABLE ROW LEVEL SECURITY; -CREATE POLICY IF NOT EXISTS "Gym members select exercises" +DROP POLICY IF EXISTS "Gym members select exercises" ON public.exercise_library; +CREATE POLICY "Gym members select exercises" ON public.exercise_library FOR SELECT USING (EXISTS ( SELECT 1 FROM user_gyms ug1 @@ -243,11 +259,13 @@ CREATE POLICY IF NOT EXISTS "Gym members select exercises" WHERE ug1.user_id = auth.uid() AND ug2.user_id = exercise_library.user_id )); -CREATE POLICY IF NOT EXISTS "Gym members insert exercises" +DROP POLICY IF EXISTS "Gym members insert exercises" ON public.exercise_library; +CREATE POLICY "Gym members insert exercises" ON public.exercise_library FOR INSERT WITH CHECK (auth.uid() = user_id); -CREATE POLICY IF NOT EXISTS "Gym members update exercises" +DROP POLICY IF EXISTS "Gym members update exercises" ON public.exercise_library; +CREATE POLICY "Gym members update exercises" ON public.exercise_library FOR UPDATE USING (EXISTS ( SELECT 1 FROM user_gyms ug1 @@ -260,7 +278,8 @@ CREATE POLICY IF NOT EXISTS "Gym members update exercises" WHERE ug1.user_id = auth.uid() AND ug2.user_id = exercise_library.user_id )); -CREATE POLICY IF NOT EXISTS "Gym members delete exercises" +DROP POLICY IF EXISTS "Gym members delete exercises" ON public.exercise_library; +CREATE POLICY "Gym members delete exercises" ON public.exercise_library FOR DELETE USING (EXISTS ( SELECT 1 FROM user_gyms ug1 @@ -285,7 +304,8 @@ GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE ON public.session_templates TO anon, authenticated, service_role; ALTER TABLE public.session_templates ENABLE ROW LEVEL SECURITY; -CREATE POLICY IF NOT EXISTS "Gym members select templates" +DROP POLICY IF EXISTS "Gym members select templates" ON public.session_templates; +CREATE POLICY "Gym members select templates" ON public.session_templates FOR SELECT USING (EXISTS ( SELECT 1 FROM user_gyms ug1 @@ -293,11 +313,13 @@ CREATE POLICY IF NOT EXISTS "Gym members select templates" WHERE ug1.user_id = auth.uid() AND ug2.user_id = session_templates.user_id )); -CREATE POLICY IF NOT EXISTS "Gym members insert templates" +DROP POLICY IF EXISTS "Gym members insert templates" ON public.session_templates; +CREATE POLICY "Gym members insert templates" ON public.session_templates FOR INSERT WITH CHECK (auth.uid() = user_id); -CREATE POLICY IF NOT EXISTS "Gym members update templates" +DROP POLICY IF EXISTS "Gym members update templates" ON public.session_templates; +CREATE POLICY "Gym members update templates" ON public.session_templates FOR UPDATE USING (EXISTS ( SELECT 1 FROM user_gyms ug1 @@ -310,7 +332,8 @@ CREATE POLICY IF NOT EXISTS "Gym members update templates" WHERE ug1.user_id = auth.uid() AND ug2.user_id = session_templates.user_id )); -CREATE POLICY IF NOT EXISTS "Gym members delete templates" +DROP POLICY IF EXISTS "Gym members delete templates" ON public.session_templates; +CREATE POLICY "Gym members delete templates" ON public.session_templates FOR DELETE USING (EXISTS ( SELECT 1 FROM user_gyms ug1 @@ -337,7 +360,8 @@ GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE ON public.session_template_exercises TO anon, authenticated, service_role; ALTER TABLE public.session_template_exercises ENABLE ROW LEVEL SECURITY; -CREATE POLICY IF NOT EXISTS "Gym members access template exercises" +DROP POLICY IF EXISTS "Gym members access template exercises" ON public.session_template_exercises; +CREATE POLICY "Gym members access template exercises" ON public.session_template_exercises FOR ALL USING (EXISTS ( SELECT 1 FROM session_templates st @@ -369,11 +393,13 @@ GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE ON public.roles TO anon, authenticated, service_role; ALTER TABLE public.roles ENABLE ROW LEVEL SECURITY; -CREATE POLICY IF NOT EXISTS "users see own roles" +DROP POLICY IF EXISTS "users see own roles" ON public.roles; +CREATE POLICY "users see own roles" ON public.roles FOR SELECT USING (auth.uid() = user_id); -CREATE POLICY IF NOT EXISTS "users manage own roles" +DROP POLICY IF EXISTS "users manage own roles" ON public.roles; +CREATE POLICY "users manage own roles" ON public.roles FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); @@ -391,17 +417,20 @@ GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE ON public.recommendation_cache TO anon, authenticated, service_role; ALTER TABLE public.recommendation_cache ENABLE ROW LEVEL SECURITY; -CREATE POLICY IF NOT EXISTS "Authenticated users can read rec cache" +DROP POLICY IF EXISTS "Authenticated users can read rec cache" ON public.recommendation_cache; +CREATE POLICY "Authenticated users can read rec cache" ON public.recommendation_cache FOR SELECT USING (auth.role() = 'authenticated'); -- Pre-ownership INSERT and UPDATE policies; replaced by 20260515_rec_cache_ownership. -CREATE POLICY IF NOT EXISTS "Authenticated users can write rec cache" +DROP POLICY IF EXISTS "Authenticated users can write rec cache" ON public.recommendation_cache; +CREATE POLICY "Authenticated users can write rec cache" ON public.recommendation_cache FOR INSERT TO authenticated WITH CHECK (auth.role() = 'authenticated'); -CREATE POLICY IF NOT EXISTS "Authenticated users can update rec cache" +DROP POLICY IF EXISTS "Authenticated users can update rec cache" ON public.recommendation_cache; +CREATE POLICY "Authenticated users can update rec cache" ON public.recommendation_cache FOR UPDATE TO authenticated USING (auth.role() = 'authenticated'); @@ -419,7 +448,8 @@ GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE ON public.week_plans TO anon, authenticated, service_role; ALTER TABLE public.week_plans ENABLE ROW LEVEL SECURITY; -CREATE POLICY IF NOT EXISTS "Users manage own week_plans" +DROP POLICY IF EXISTS "Users manage own week_plans" ON public.week_plans; +CREATE POLICY "Users manage own week_plans" ON public.week_plans FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); @@ -437,7 +467,8 @@ GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER, TRUNCATE ON public.week_plan_days TO anon, authenticated, service_role; ALTER TABLE public.week_plan_days ENABLE ROW LEVEL SECURITY; -CREATE POLICY IF NOT EXISTS "Users manage own week_plan_days" +DROP POLICY IF EXISTS "Users manage own week_plan_days" ON public.week_plan_days; +CREATE POLICY "Users manage own week_plan_days" ON public.week_plan_days FOR ALL USING (EXISTS ( SELECT 1 FROM week_plans wp @@ -615,7 +646,8 @@ $$; -- ── Auth trigger ────────────────────────────────────────────────────────────── -- Fires after every new auth.users row to create the matching profiles row. -CREATE TRIGGER IF NOT EXISTS on_auth_user_created +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION handle_new_user(); diff --git a/supabase/migrations/20260515_rec_cache_ownership.sql b/supabase/migrations/20260515_rec_cache_ownership.sql index dbaf790..5d2ba15 100644 --- a/supabase/migrations/20260515_rec_cache_ownership.sql +++ b/supabase/migrations/20260515_rec_cache_ownership.sql @@ -21,7 +21,7 @@ ALTER TABLE public.recommendation_cache -- Tighten INSERT: written_by must equal the calling user. DROP POLICY IF EXISTS "Authenticated users can write rec cache" ON public.recommendation_cache; -CREATE POLICY IF NOT EXISTS "Authenticated users can write rec cache" +CREATE POLICY "Authenticated users can write rec cache" ON public.recommendation_cache FOR INSERT TO authenticated WITH CHECK (written_by = auth.uid()); @@ -31,7 +31,7 @@ CREATE POLICY IF NOT EXISTS "Authenticated users can write rec cache" -- skipped (no error) — the existing entry stays, preventing poisoning. DROP POLICY IF EXISTS "Authenticated users can update rec cache" ON public.recommendation_cache; -CREATE POLICY IF NOT EXISTS "Cache owner can update their entry" +CREATE POLICY "Cache owner can update their entry" ON public.recommendation_cache FOR UPDATE TO authenticated USING (written_by = auth.uid()); From efc1d5814e004b06ef20ae910ea0e212aafdfbc1 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Fri, 15 May 2026 23:16:17 +0200 Subject: [PATCH 3/4] fix: remove UTF-8 BOM from baseline migration (PostgreSQL rejects it) --- supabase/migrations/20260101000000_baseline_schema.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/migrations/20260101000000_baseline_schema.sql b/supabase/migrations/20260101000000_baseline_schema.sql index 980d641..bafcdaa 100644 --- a/supabase/migrations/20260101000000_baseline_schema.sql +++ b/supabase/migrations/20260101000000_baseline_schema.sql @@ -1,4 +1,4 @@ --- Baseline schema snapshot for Workout Lens. +-- Baseline schema snapshot for Workout Lens. -- -- This migration was added retroactively to allow Supabase preview branches and -- local `supabase db reset` to work. All previous schema changes were applied From 016491943f8a5a51ef7b13a30d63007fb164eb83 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Fri, 15 May 2026 23:25:30 +0200 Subject: [PATCH 4/4] chore: retrigger CI after Supabase GitHub App removal