|
| 1 | +-- exercise_evaluations: unified AI-evaluation record across all exercise engines |
| 2 | +-- (text/simulation via lib/ai/tools.ts + artifact/evaluate route, audio/video via media/analyze route). |
| 3 | +-- Code wrote+read this table for months but the CREATE was never shipped, so every AI |
| 4 | +-- exercise-evaluation insert failed silently (relation-not-found) and the student exercise |
| 5 | +-- history + the artifact-route rate-limit both read nothing. This migration adds the table |
| 6 | +-- the live code already expects. Shape derived verbatim from the insert/select call sites. |
| 7 | + |
| 8 | +create table if not exists public.exercise_evaluations ( |
| 9 | + id bigint generated by default as identity primary key, |
| 10 | + exercise_id bigint not null references public.exercises(id) on delete cascade, |
| 11 | + user_id uuid not null references public.profiles(id) on delete cascade, |
| 12 | + tenant_id uuid not null references public.tenants(id) on delete cascade, |
| 13 | + engine_type text not null, -- 'text' | 'simulation' | 'audio' | 'video' |
| 14 | + submission_id bigint, -- polymorphic ref (e.g. exercise_media_submissions.id); no FK |
| 15 | + submission_source text, -- e.g. 'exercise_media_submissions' |
| 16 | + score numeric(5,2), |
| 17 | + passed boolean not null default false, |
| 18 | + ai_result jsonb not null default '{}'::jsonb, |
| 19 | + ai_metrics jsonb, |
| 20 | + attempt_number integer not null default 1, -- auto-assigned by trigger (no caller sets it) |
| 21 | + created_at timestamptz not null default now() |
| 22 | +); |
| 23 | + |
| 24 | +-- Serves both hot reads: page history (exercise_id+user_id+tenant_id, order by created_at desc) |
| 25 | +-- and the artifact-route rate-limit count (same keys + created_at >= now()-1h). |
| 26 | +create index if not exists exercise_evaluations_lookup_idx |
| 27 | + on public.exercise_evaluations (exercise_id, user_id, tenant_id, created_at desc); |
| 28 | + |
| 29 | +-- attempt_number is read by the student exercise page but written by no caller; auto-number it |
| 30 | +-- per (user, exercise) so the displayed history is meaningful. |
| 31 | +create or replace function public.set_exercise_evaluation_attempt_number() |
| 32 | +returns trigger |
| 33 | +language plpgsql |
| 34 | +as $$ |
| 35 | +begin |
| 36 | + select count(*) + 1 |
| 37 | + into new.attempt_number |
| 38 | + from public.exercise_evaluations |
| 39 | + where exercise_id = new.exercise_id |
| 40 | + and user_id = new.user_id; |
| 41 | + return new; |
| 42 | +end; |
| 43 | +$$; |
| 44 | + |
| 45 | +create trigger trg_exercise_evaluation_attempt |
| 46 | + before insert on public.exercise_evaluations |
| 47 | + for each row execute function public.set_exercise_evaluation_attempt_number(); |
| 48 | + |
| 49 | +-- RLS: a student has full access to their own rows; a teacher/admin may read any row in a |
| 50 | +-- tenant where they hold an active role. Tenant scope is derived from authoritative |
| 51 | +-- `tenant_users` membership (bound to auth.uid()), NOT the `x-tenant-id` request header — |
| 52 | +-- the header is client-settable on a direct PostgREST call, so a header-only teacher policy |
| 53 | +-- would let any authenticated user read another tenant's evaluations by spoofing it. |
| 54 | +-- Server-side writes use the service-role admin client and bypass RLS. |
| 55 | +alter table public.exercise_evaluations enable row level security; |
| 56 | + |
| 57 | +-- Students: own rows only. user_id is globally unique and bound to the JWT, so this fully |
| 58 | +-- scopes access without any tenant header. |
| 59 | +create policy students_own_evaluations on public.exercise_evaluations |
| 60 | + for all |
| 61 | + using ( user_id = (select auth.uid()) ) |
| 62 | + with check ( user_id = (select auth.uid()) ); |
| 63 | + |
| 64 | +-- Teachers/admins: rows in tenants where they hold an active teacher/admin membership. |
| 65 | +create policy teachers_view_tenant_evaluations on public.exercise_evaluations |
| 66 | + for select |
| 67 | + using ( |
| 68 | + exists ( |
| 69 | + select 1 |
| 70 | + from public.tenant_users tu |
| 71 | + where tu.user_id = (select auth.uid()) |
| 72 | + and tu.tenant_id = exercise_evaluations.tenant_id |
| 73 | + and tu.role in ('teacher', 'admin') |
| 74 | + and tu.status = 'active' |
| 75 | + ) |
| 76 | + ); |
0 commit comments