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..bafcdaa
--- /dev/null
+++ b/supabase/migrations/20260101000000_baseline_schema.sql
@@ -0,0 +1,653 @@
+-- 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;
+
+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);
+
+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);
+
+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
+ 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;
+
+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());
+
+-- ── 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;
+
+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);
+
+-- ── 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;
+
+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);
+
+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);
+
+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);
+
+-- ── 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;
+
+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());
+
+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);
+
+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
+ 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;
+
+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
+ WHERE sessions.id = session_exercises.session_id
+ AND sessions.trainer_id = auth.uid()
+ ));
+
+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 (
+ 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;
+
+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
+ JOIN sessions s ON s.id = se.session_id
+ WHERE se.id = muscle_activations.session_exercise_id
+ AND s.trainer_id = auth.uid()
+ ));
+
+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 (
+ 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;
+
+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
+ 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
+ ));
+
+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);
+
+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
+ 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
+ ));
+
+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
+ 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;
+
+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
+ 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
+ ));
+
+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);
+
+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
+ 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
+ ));
+
+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
+ 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;
+
+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
+ 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;
+
+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);
+
+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);
+
+-- ── 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;
+
+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.
+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');
+
+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');
+
+-- ── 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;
+
+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);
+
+-- ── 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;
+
+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
+ 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.
+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/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..5d2ba15 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
+ 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
+ ON public.recommendation_cache FOR UPDATE
TO authenticated
USING (written_by = auth.uid());