Compare commits
9 Commits
archive/ag
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ba7523d1c | ||
|
|
e856de79b8 | ||
|
|
75a0779a3a | ||
|
|
0f2aca3a73 | ||
| f8fcff600f | |||
| 89db695555 | |||
| feceaf64b6 | |||
| 10314ddd62 | |||
| fcab68f57a |
@ -355,16 +355,17 @@ services:
|
|||||||
- ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/59-logs.sql:Z
|
- ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/59-logs.sql:Z
|
||||||
# Changes required for Pooler support
|
# Changes required for Pooler support
|
||||||
- ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/59-pooler.sql:Z
|
- ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/59-pooler.sql:Z
|
||||||
# Classroom Copilot consolidated application schema and deterministic dev seed.
|
# ClassroomCopilot changes
|
||||||
# Keep this chain ordered; GAIS reference tables are schema-only here, while
|
- ./volumes/db/cc/61-core-schema.sql:/docker-entrypoint-initdb.d/migrations/61-core-schema.sql:Z
|
||||||
# full GAIS open-data bulk loads remain outside the small dev seed.
|
- ./volumes/db/cc/62-functions-triggers.sql:/docker-entrypoint-initdb.d/migrations/62-functions-triggers.sql:Z
|
||||||
- ./volumes/db/cc/61-gais-reference.sql:/docker-entrypoint-initdb.d/migrations/61-gais-reference.sql:Z
|
- ./volumes/db/cc/63-storage-policies.sql:/docker-entrypoint-initdb.d/migrations/63-storage-policies.sql:Z
|
||||||
- ./volumes/db/cc/62-application-schema.sql:/docker-entrypoint-initdb.d/migrations/62-application-schema.sql:Z
|
- ./volumes/db/cc/64-initial-admin.sql:/docker-entrypoint-initdb.d/migrations/64-initial-admin.sql:Z
|
||||||
- ./volumes/db/cc/63-academic-calendar.sql:/docker-entrypoint-initdb.d/migrations/63-academic-calendar.sql:Z
|
- ./volumes/db/cc/65-filesystem-augments.sql:/docker-entrypoint-initdb.d/migrations/65-filesystem-augments.sql:Z
|
||||||
- ./volumes/db/cc/64-extended-schema.sql:/docker-entrypoint-initdb.d/migrations/64-extended-schema.sql:Z
|
- ./volumes/db/cc/66-rls-policies.sql:/docker-entrypoint-initdb.d/migrations/66-rls-policies.sql:Z
|
||||||
- ./volumes/db/cc/65-phase-c.sql:/docker-entrypoint-initdb.d/migrations/65-phase-c.sql:Z
|
- ./volumes/db/cc/67-vectors.sql:/docker-entrypoint-initdb.d/migrations/67-vectors.sql:Z
|
||||||
- ./volumes/db/cc/66-taught-lessons-nullable.sql:/docker-entrypoint-initdb.d/migrations/66-taught-lessons-nullable.sql:Z
|
- ./volumes/db/cc/68-cabinet-memberships.sql:/docker-entrypoint-initdb.d/migrations/68-cabinet-memberships.sql:Z
|
||||||
- ./volumes/db/cc/67-dev-seed.sql:/docker-entrypoint-initdb.d/migrations/67-dev-seed.sql:Z
|
- ./volumes/db/cc/69-gc-prefix-cleanup.sql:/docker-entrypoint-initdb.d/migrations/69-gc-prefix-cleanup.sql:Z
|
||||||
|
- ./volumes/db/cc/70-add-directory-support.sql:/docker-entrypoint-initdb.d/migrations/70-add-directory-support.sql:Z
|
||||||
# PGDATA directory - persists database files between restarts
|
# PGDATA directory - persists database files between restarts
|
||||||
- ./volumes/db-data:/var/lib/postgresql/data:Z
|
- ./volumes/db-data:/var/lib/postgresql/data:Z
|
||||||
# Use named volume to persist pgsodium decryption key between restarts
|
# Use named volume to persist pgsodium decryption key between restarts
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
# Classroom Copilot Supabase migrations and deterministic dev seed
|
|
||||||
|
|
||||||
Status: branch implementation validated on Supabase dev host 192.168.0.94 using an isolated throwaway database.
|
|
||||||
|
|
||||||
## Consolidated init chain
|
|
||||||
|
|
||||||
The Docker Compose `db` service now mounts a single ordered Classroom Copilot chain:
|
|
||||||
|
|
||||||
1. `volumes/db/cc/61-gais-reference.sql` — GAIS reference table schema and open-data read policies only.
|
|
||||||
2. `volumes/db/cc/62-application-schema.sql` — canonical app schema, storage/file metadata tables, class/lesson/CIS tables, indexes, and base RLS.
|
|
||||||
3. `volumes/db/cc/63-academic-calendar.sql` — school timetable, academic year/term/week/day, teacher timetable, and teacher slot tables.
|
|
||||||
4. `volumes/db/cc/64-extended-schema.sql` — term breaks, academic periods, invitations, taught lessons, and week-cycle slot uniqueness.
|
|
||||||
5. `volumes/db/cc/65-phase-c.sql` — Phase C cleanup after taught lessons exist; links lesson deliveries to taught lessons.
|
|
||||||
6. `volumes/db/cc/66-taught-lessons-nullable.sql` — nullable taught lesson `class_id` and teacher slot class FK.
|
|
||||||
7. `volumes/db/cc/67-dev-seed.sql` — deterministic, non-sensitive dev fixtures.
|
|
||||||
|
|
||||||
The old `61-core-schema.sql` through `70-add-directory-support.sql` bootstrap files were removed from the active chain because they represented an older ClassConcepts/filesystem schema and stale role vocabulary. The Git history remains the archive.
|
|
||||||
|
|
||||||
## Deterministic dev seed contents
|
|
||||||
|
|
||||||
`67-dev-seed.sql` creates only fixture data:
|
|
||||||
|
|
||||||
- 1 platform admin in `admin_profiles`.
|
|
||||||
- 1 school/institute.
|
|
||||||
- 1 school admin, 2 teachers, 3 students.
|
|
||||||
- institute memberships for the school admin, teachers, and students.
|
|
||||||
- 2 classes with class-teacher and class-student rows.
|
|
||||||
- 1 school timetable, 1 academic year, 1 term, 1 week, 4 academic days, and 16 academic periods.
|
|
||||||
- 2 teacher timetables, 3 teacher timetable slots, and 3 taught lessons.
|
|
||||||
- 2 planned lessons and 1 delivered lesson fixture.
|
|
||||||
- Storage buckets `cc.users`, `cc.public.snapshots`, and `cc.examboards`.
|
|
||||||
- TLDraw default snapshot paths on the teacher whiteboard rooms; object rows are not pre-created.
|
|
||||||
|
|
||||||
Fixture emails use the `classroomcopilot.dev` domain and are not real users. Do not replace this seed with live student/teacher data.
|
|
||||||
|
|
||||||
## Validation pattern used on Supabase dev
|
|
||||||
|
|
||||||
Do not run schema experiments on production. To validate this branch without mutating the live dev database, create a throwaway database on the Supabase dev Postgres container, clone only the `auth` and `storage` schema definitions from dev, apply the ordered chain, check row counts, then drop the throwaway database.
|
|
||||||
|
|
||||||
The 2026-05-28 validation used this shape on `ubuntu-ct-supabase-dev` (`192.168.0.94`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DB=cc_mig_validate_<timestamp>
|
|
||||||
BASE=/tmp/cc-supabase-migration-validate
|
|
||||||
|
|
||||||
docker exec supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS $DB WITH (FORCE);"
|
|
||||||
docker exec supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE $DB;"
|
|
||||||
|
|
||||||
# The dev auth schema has a trigger that references public.handle_new_user();
|
|
||||||
# create a no-op stub before restoring auth/storage schema-only into the throwaway DB.
|
|
||||||
cat >/tmp/create_dummy.sql <<'SQL'
|
|
||||||
create or replace function public.handle_new_user()
|
|
||||||
returns trigger
|
|
||||||
language plpgsql
|
|
||||||
as $$
|
|
||||||
begin
|
|
||||||
return new;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
SQL
|
|
||||||
docker exec -i supabase-db psql -U postgres -d "$DB" -v ON_ERROR_STOP=1 < /tmp/create_dummy.sql
|
|
||||||
|
|
||||||
docker exec supabase-db pg_dump -U postgres -d postgres --schema-only --no-owner --no-privileges -n auth -n storage > /tmp/${DB}_auth_storage_schema.sql
|
|
||||||
docker exec -i supabase-db psql -U postgres -d "$DB" -v ON_ERROR_STOP=1 < /tmp/${DB}_auth_storage_schema.sql
|
|
||||||
|
|
||||||
for f in \
|
|
||||||
"$BASE"/volumes/db/cc/61-gais-reference.sql \
|
|
||||||
"$BASE"/volumes/db/cc/62-application-schema.sql \
|
|
||||||
"$BASE"/volumes/db/cc/63-academic-calendar.sql \
|
|
||||||
"$BASE"/volumes/db/cc/64-extended-schema.sql \
|
|
||||||
"$BASE"/volumes/db/cc/65-phase-c.sql \
|
|
||||||
"$BASE"/volumes/db/cc/66-taught-lessons-nullable.sql \
|
|
||||||
"$BASE"/volumes/db/cc/67-dev-seed.sql; do
|
|
||||||
docker exec -i supabase-db psql -U postgres -d "$DB" -v ON_ERROR_STOP=1 < "$f"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Smoke counts, then cleanup.
|
|
||||||
docker exec supabase-db psql -U postgres -d "$DB" -Atc "select 'profiles='||count(*) from public.profiles union all select 'institutes='||count(*) from public.institutes union all select 'memberships='||count(*) from public.institute_memberships union all select 'classes='||count(*) from public.classes union all select 'academic_periods='||count(*) from public.academic_periods union all select 'teacher_timetable_slots='||count(*) from public.teacher_timetable_slots union all select 'taught_lessons='||count(*) from public.taught_lessons union all select 'planned_lessons='||count(*) from public.planned_lessons union all select 'lesson_deliveries='||count(*) from public.lesson_deliveries union all select 'buckets='||count(*) from storage.buckets where id like 'cc.%';"
|
|
||||||
docker exec supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS $DB WITH (FORCE);"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected smoke counts from the deterministic seed:
|
|
||||||
|
|
||||||
```text
|
|
||||||
profiles=7
|
|
||||||
institutes=1
|
|
||||||
memberships=6
|
|
||||||
classes=2
|
|
||||||
academic_periods=16
|
|
||||||
teacher_timetable_slots=3
|
|
||||||
taught_lessons=3
|
|
||||||
planned_lessons=2
|
|
||||||
lesson_deliveries=1
|
|
||||||
buckets=3
|
|
||||||
```
|
|
||||||
|
|
||||||
## Production rule
|
|
||||||
|
|
||||||
This branch is not a production migration by itself. Before production use:
|
|
||||||
|
|
||||||
1. Take a production schema/data backup.
|
|
||||||
2. Compare live production schema drift against this consolidated chain.
|
|
||||||
3. Prepare explicit forward migrations for any live-only objects or data transforms.
|
|
||||||
4. Validate those forward migrations on Supabase dev first.
|
|
||||||
5. Only apply to production after human approval.
|
|
||||||
@ -51,6 +51,7 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
|
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -95,6 +96,7 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
|
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -139,6 +141,7 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
|
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -185,6 +188,7 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
|
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -241,6 +245,7 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
|
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -297,6 +302,7 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
|
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -359,6 +365,7 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
|
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -414,6 +421,7 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
|
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -469,6 +477,7 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
|
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -516,6 +525,7 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
|
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -596,6 +606,7 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
|
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
||||||
- "http://192.168.0.94:50001"
|
- "http://192.168.0.94:50001"
|
||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
methods:
|
methods:
|
||||||
|
|||||||
364
volumes/db/cc/61-core-schema.sql
Normal file
364
volumes/db/cc/61-core-schema.sql
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
--[ Database Schema Version ]--
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- Last Updated: 2024-02-24
|
||||||
|
-- Description: Core schema setup for ClassConcepts with neoFS filesystem integration
|
||||||
|
-- Dependencies: auth.users (Supabase Auth)
|
||||||
|
|
||||||
|
--[ Validation ]--
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
-- Verify required extensions
|
||||||
|
if not exists (select 1 from pg_extension where extname = 'uuid-ossp') then
|
||||||
|
raise exception 'Required extension uuid-ossp is not installed';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Verify auth schema exists
|
||||||
|
if not exists (select 1 from information_schema.schemata where schema_name = 'auth') then
|
||||||
|
raise exception 'Required auth schema is not available';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Verify storage schema exists
|
||||||
|
if not exists (select 1 from information_schema.schemata where schema_name = 'storage') then
|
||||||
|
raise exception 'Required storage schema is not available';
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
--[ 1. Extensions ]--
|
||||||
|
create extension if not exists "uuid-ossp";
|
||||||
|
|
||||||
|
-- Create rpc schema if it doesn't exist
|
||||||
|
create schema if not exists rpc;
|
||||||
|
grant usage on schema rpc to anon, authenticated;
|
||||||
|
|
||||||
|
-- Create exec_sql function for admin operations
|
||||||
|
create or replace function exec_sql(query text)
|
||||||
|
returns void as $$
|
||||||
|
begin
|
||||||
|
execute query;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql security definer;
|
||||||
|
|
||||||
|
-- Create updated_at trigger function
|
||||||
|
create or replace function public.handle_updated_at()
|
||||||
|
returns trigger as $$
|
||||||
|
begin
|
||||||
|
new.updated_at = timezone('utc'::text, now());
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql security definer;
|
||||||
|
|
||||||
|
-- Create completed_at trigger function for document artefacts
|
||||||
|
create or replace function public.set_completed_at()
|
||||||
|
returns trigger as $$
|
||||||
|
begin
|
||||||
|
if NEW.status = 'completed' and OLD.status != 'completed' then
|
||||||
|
NEW.completed_at = now();
|
||||||
|
end if;
|
||||||
|
return NEW;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql security definer;
|
||||||
|
|
||||||
|
--[ 5. Core Tables ]--
|
||||||
|
-- Base user profiles
|
||||||
|
create table if not exists public.profiles (
|
||||||
|
id uuid primary key references auth.users(id) on delete cascade,
|
||||||
|
email text not null unique,
|
||||||
|
user_type text not null check (
|
||||||
|
user_type in (
|
||||||
|
'teacher',
|
||||||
|
'student',
|
||||||
|
'email_teacher',
|
||||||
|
'email_student',
|
||||||
|
'developer',
|
||||||
|
'superadmin'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
username text not null unique,
|
||||||
|
full_name text,
|
||||||
|
display_name text,
|
||||||
|
metadata jsonb default '{}'::jsonb,
|
||||||
|
user_db_name text,
|
||||||
|
school_db_name text,
|
||||||
|
neo4j_sync_status text default 'pending' check (neo4j_sync_status in ('pending', 'ready', 'failed')),
|
||||||
|
neo4j_synced_at timestamp with time zone,
|
||||||
|
last_login timestamp with time zone,
|
||||||
|
created_at timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
updated_at timestamp with time zone default timezone('utc'::text, now())
|
||||||
|
);
|
||||||
|
comment on table public.profiles is 'User profiles linked to Supabase auth.users';
|
||||||
|
comment on column public.profiles.user_type is 'Type of user: teacher or student';
|
||||||
|
|
||||||
|
-- Active institutes
|
||||||
|
create table if not exists public.institutes (
|
||||||
|
id uuid primary key default uuid_generate_v4(),
|
||||||
|
name text not null,
|
||||||
|
urn text unique,
|
||||||
|
status text not null default 'active' check (status in ('active', 'inactive', 'pending')),
|
||||||
|
address jsonb default '{}'::jsonb,
|
||||||
|
website text,
|
||||||
|
metadata jsonb default '{}'::jsonb,
|
||||||
|
geo_coordinates jsonb default '{}'::jsonb,
|
||||||
|
neo4j_uuid_string text,
|
||||||
|
neo4j_public_sync_status text default 'pending' check (neo4j_public_sync_status in ('pending', 'synced', 'failed')),
|
||||||
|
neo4j_public_sync_at timestamp with time zone,
|
||||||
|
neo4j_private_sync_status text default 'not_started' check (neo4j_private_sync_status in ('not_started', 'pending', 'synced', 'failed')),
|
||||||
|
neo4j_private_sync_at timestamp with time zone,
|
||||||
|
created_at timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
updated_at timestamp with time zone default timezone('utc'::text, now())
|
||||||
|
);
|
||||||
|
comment on table public.institutes is 'Active institutes in the system';
|
||||||
|
comment on column public.institutes.geo_coordinates is 'Geospatial coordinates from OSM search (latitude, longitude, boundingbox)';
|
||||||
|
|
||||||
|
--[ 6. neoFS Filesystem Tables ]--
|
||||||
|
-- File cabinets for organizing files
|
||||||
|
create table if not exists public.file_cabinets (
|
||||||
|
id uuid primary key default uuid_generate_v4(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
created_at timestamp with time zone default timezone('utc'::text, now())
|
||||||
|
);
|
||||||
|
comment on table public.file_cabinets is 'User file cabinets for organizing documents and files';
|
||||||
|
|
||||||
|
-- Files stored in cabinets
|
||||||
|
create table if not exists public.files (
|
||||||
|
id uuid primary key default uuid_generate_v4(),
|
||||||
|
cabinet_id uuid not null references public.file_cabinets(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
path text not null,
|
||||||
|
bucket text default 'file-cabinets' not null,
|
||||||
|
created_at timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
mime_type text,
|
||||||
|
metadata jsonb default '{}'::jsonb,
|
||||||
|
size text,
|
||||||
|
category text generated always as (
|
||||||
|
case
|
||||||
|
when mime_type like 'image/%' then 'image'
|
||||||
|
when mime_type = 'application/pdf' then 'document'
|
||||||
|
when mime_type in ('application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') then 'document'
|
||||||
|
when mime_type in ('application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') then 'spreadsheet'
|
||||||
|
when mime_type in ('application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation') then 'presentation'
|
||||||
|
when mime_type like 'audio/%' then 'audio'
|
||||||
|
when mime_type like 'video/%' then 'video'
|
||||||
|
else 'other'
|
||||||
|
end
|
||||||
|
) stored
|
||||||
|
);
|
||||||
|
comment on table public.files is 'Files stored in user cabinets with automatic categorization';
|
||||||
|
comment on column public.files.category is 'Automatically determined file category based on MIME type';
|
||||||
|
|
||||||
|
-- AI brains for processing files
|
||||||
|
create table if not exists public.brains (
|
||||||
|
id uuid primary key default uuid_generate_v4(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
purpose text,
|
||||||
|
created_at timestamp with time zone default timezone('utc'::text, now())
|
||||||
|
);
|
||||||
|
comment on table public.brains is 'AI brains for processing and analyzing user files';
|
||||||
|
|
||||||
|
-- Brain-file associations
|
||||||
|
create table if not exists public.brain_files (
|
||||||
|
brain_id uuid not null references public.brains(id) on delete cascade,
|
||||||
|
file_id uuid not null references public.files(id) on delete cascade,
|
||||||
|
primary key (brain_id, file_id)
|
||||||
|
);
|
||||||
|
comment on table public.brain_files is 'Associations between AI brains and files for processing';
|
||||||
|
|
||||||
|
-- Document artefacts from file processing
|
||||||
|
create table if not exists public.document_artefacts (
|
||||||
|
id uuid primary key default uuid_generate_v4(),
|
||||||
|
file_id uuid references public.files(id) on delete cascade,
|
||||||
|
page_number integer default 0 not null,
|
||||||
|
type text not null,
|
||||||
|
rel_path text not null,
|
||||||
|
size_tag text,
|
||||||
|
language text,
|
||||||
|
chunk_index integer,
|
||||||
|
extra jsonb,
|
||||||
|
created_at timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
status text default 'completed' not null check (status in ('pending', 'processing', 'completed', 'failed')),
|
||||||
|
started_at timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
completed_at timestamp with time zone,
|
||||||
|
error_message text
|
||||||
|
);
|
||||||
|
comment on table public.document_artefacts is 'Extracted artefacts from document processing';
|
||||||
|
comment on column public.document_artefacts.status is 'Extraction status: pending, processing, completed, or failed';
|
||||||
|
comment on column public.document_artefacts.started_at is 'Timestamp when extraction process started';
|
||||||
|
comment on column public.document_artefacts.completed_at is 'Timestamp when extraction process completed (success or failure)';
|
||||||
|
comment on column public.document_artefacts.error_message is 'Error details if extraction failed';
|
||||||
|
|
||||||
|
-- Function execution logs
|
||||||
|
create table if not exists public.function_logs (
|
||||||
|
id serial primary key,
|
||||||
|
file_id uuid references public.files(id) on delete cascade,
|
||||||
|
timestamp timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
step text,
|
||||||
|
message text,
|
||||||
|
data jsonb
|
||||||
|
);
|
||||||
|
comment on table public.function_logs is 'Logs of function executions and processing steps';
|
||||||
|
|
||||||
|
--[ 7. Relationship Tables ]--
|
||||||
|
-- Institute memberships
|
||||||
|
create table if not exists public.institute_memberships (
|
||||||
|
id uuid primary key default uuid_generate_v4(),
|
||||||
|
profile_id uuid references public.profiles(id) on delete cascade,
|
||||||
|
institute_id uuid references public.institutes(id) on delete cascade,
|
||||||
|
role text not null check (role in ('teacher', 'student')),
|
||||||
|
tldraw_preferences jsonb default '{}'::jsonb,
|
||||||
|
metadata jsonb default '{}'::jsonb,
|
||||||
|
created_at timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
updated_at timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
unique(profile_id, institute_id)
|
||||||
|
);
|
||||||
|
comment on table public.institute_memberships is 'Manages user roles and relationships with institutes';
|
||||||
|
|
||||||
|
-- Membership requests
|
||||||
|
create table if not exists public.institute_membership_requests (
|
||||||
|
id uuid primary key default uuid_generate_v4(),
|
||||||
|
profile_id uuid references public.profiles(id) on delete cascade,
|
||||||
|
institute_id uuid references public.institutes(id) on delete cascade,
|
||||||
|
requested_role text check (requested_role in ('teacher', 'student')),
|
||||||
|
status text default 'pending' check (status in ('pending', 'approved', 'rejected')),
|
||||||
|
metadata jsonb default '{}'::jsonb,
|
||||||
|
created_at timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
updated_at timestamp with time zone default timezone('utc'::text, now())
|
||||||
|
);
|
||||||
|
comment on table public.institute_membership_requests is 'Tracks requests to join institutes';
|
||||||
|
|
||||||
|
--[ 8. Audit Tables ]--
|
||||||
|
-- System audit logs
|
||||||
|
create table if not exists public.audit_logs (
|
||||||
|
id uuid primary key default uuid_generate_v4(),
|
||||||
|
profile_id uuid references public.profiles(id) on delete set null,
|
||||||
|
action_type text,
|
||||||
|
table_name text,
|
||||||
|
record_id uuid,
|
||||||
|
changes jsonb,
|
||||||
|
created_at timestamp with time zone default timezone('utc'::text, now())
|
||||||
|
);
|
||||||
|
comment on table public.audit_logs is 'System-wide audit trail for important operations';
|
||||||
|
|
||||||
|
--[ 9. Exam Specifications ]--
|
||||||
|
create table if not exists public.eb_specifications (
|
||||||
|
id uuid primary key default uuid_generate_v4(),
|
||||||
|
spec_code text unique,
|
||||||
|
exam_board_code text,
|
||||||
|
award_code text,
|
||||||
|
subject_code text,
|
||||||
|
first_teach text,
|
||||||
|
spec_ver text,
|
||||||
|
|
||||||
|
-- Document storage details
|
||||||
|
storage_loc text,
|
||||||
|
doc_type text check (doc_type in ('pdf', 'json', 'md', 'html', 'txt', 'doctags')),
|
||||||
|
doc_details jsonb default '{}'::jsonb, -- e.g. Tika extract
|
||||||
|
docling_docs jsonb default '{}'::jsonb, -- e.g. Docling extracts settings and storage locations
|
||||||
|
|
||||||
|
created_at timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
updated_at timestamp with time zone default timezone('utc'::text, now())
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table public.eb_specifications is 'Exam board specifications and their primary document';
|
||||||
|
comment on column public.eb_specifications.spec_code is 'Unique code for the specification, used for linking exams';
|
||||||
|
comment on column public.eb_specifications.doc_details is 'Tika extract of the specification document';
|
||||||
|
comment on column public.eb_specifications.docling_docs is 'Docling extracts settings and storage locations for the specification document';
|
||||||
|
|
||||||
|
--[ 10. Exam Papers / Entries ]--
|
||||||
|
create table if not exists public.eb_exams (
|
||||||
|
id uuid primary key default uuid_generate_v4(),
|
||||||
|
exam_code text unique,
|
||||||
|
spec_code text references public.eb_specifications(spec_code) on delete cascade,
|
||||||
|
paper_code text,
|
||||||
|
tier text,
|
||||||
|
session text,
|
||||||
|
type_code text,
|
||||||
|
|
||||||
|
-- Document storage details
|
||||||
|
storage_loc text,
|
||||||
|
doc_type text check (doc_type in ('pdf', 'json', 'md', 'html', 'txt', 'doctags')),
|
||||||
|
doc_details jsonb default '{}'::jsonb, -- e.g. Tika extract
|
||||||
|
docling_docs jsonb default '{}'::jsonb, -- e.g. Docling extracts settings and storage locations
|
||||||
|
|
||||||
|
created_at timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
updated_at timestamp with time zone default timezone('utc'::text, now())
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table public.eb_exams is 'Exam papers and related documents linked to specifications';
|
||||||
|
comment on column public.eb_exams.exam_code is 'Unique code for the exam paper, used for linking questions';
|
||||||
|
comment on column public.eb_exams.type_code is 'Type code for the exam document: Question Paper (QP), Mark Scheme (MS), Examiner Report (ER), Other (OT)';
|
||||||
|
comment on column public.eb_exams.doc_details is 'Tika extract of the exam paper document';
|
||||||
|
comment on column public.eb_exams.docling_docs is 'Docling extracts settings and storage locations for the exam paper document';
|
||||||
|
|
||||||
|
--[ 11. Indexes ]--
|
||||||
|
-- Index for geospatial queries
|
||||||
|
create index if not exists idx_institutes_geo_coordinates on public.institutes using gin(geo_coordinates);
|
||||||
|
create index if not exists idx_institutes_urn on public.institutes(urn);
|
||||||
|
|
||||||
|
-- Document artefacts indexes
|
||||||
|
create index if not exists idx_document_artefacts_file_status on public.document_artefacts(file_id, status);
|
||||||
|
create index if not exists idx_document_artefacts_file_type on public.document_artefacts(file_id, type);
|
||||||
|
create index if not exists idx_document_artefacts_status on public.document_artefacts(status);
|
||||||
|
|
||||||
|
-- File indexes
|
||||||
|
create index if not exists idx_files_cabinet_id on public.files(cabinet_id);
|
||||||
|
create index if not exists idx_files_mime_type on public.files(mime_type);
|
||||||
|
create index if not exists idx_files_category on public.files(category);
|
||||||
|
|
||||||
|
-- Brain indexes
|
||||||
|
create index if not exists idx_brains_user_id on public.brains(user_id);
|
||||||
|
|
||||||
|
-- Exam board indexes
|
||||||
|
create index if not exists idx_eb_exams_exam_code on public.eb_exams(exam_code);
|
||||||
|
create index if not exists idx_eb_exams_spec_code on public.eb_exams(spec_code);
|
||||||
|
create index if not exists idx_eb_exams_paper_code on public.eb_exams(paper_code);
|
||||||
|
create index if not exists idx_eb_exams_tier on public.eb_exams(tier);
|
||||||
|
create index if not exists idx_eb_exams_session on public.eb_exams(session);
|
||||||
|
create index if not exists idx_eb_exams_type_code on public.eb_exams(type_code);
|
||||||
|
create index if not exists idx_eb_specifications_spec_code on public.eb_specifications(spec_code);
|
||||||
|
create index if not exists idx_eb_specifications_exam_board_code on public.eb_specifications(exam_board_code);
|
||||||
|
create index if not exists idx_eb_specifications_award_code on public.eb_specifications(award_code);
|
||||||
|
create index if not exists idx_eb_specifications_subject_code on public.eb_specifications(subject_code);
|
||||||
|
|
||||||
|
--[ 12. Triggers ]--
|
||||||
|
-- Set completed_at when document artefact status changes to completed
|
||||||
|
create trigger trigger_set_completed_at
|
||||||
|
before update on public.document_artefacts
|
||||||
|
for each row
|
||||||
|
execute function public.set_completed_at();
|
||||||
|
|
||||||
|
-- Set updated_at on profile updates
|
||||||
|
create trigger trigger_profiles_updated_at
|
||||||
|
before update on public.profiles
|
||||||
|
for each row
|
||||||
|
execute function public.handle_updated_at();
|
||||||
|
|
||||||
|
-- Set updated_at on institute updates
|
||||||
|
create trigger trigger_institutes_updated_at
|
||||||
|
before update on public.institutes
|
||||||
|
for each row
|
||||||
|
execute function public.handle_updated_at();
|
||||||
|
|
||||||
|
-- Set updated_at on institute_memberships updates
|
||||||
|
create trigger trigger_institute_memberships_updated_at
|
||||||
|
before update on public.institute_memberships
|
||||||
|
for each row
|
||||||
|
execute function public.handle_updated_at();
|
||||||
|
|
||||||
|
-- Set updated_at on institute_membership_requests updates
|
||||||
|
create trigger trigger_institute_membership_requests_updated_at
|
||||||
|
before update on public.institute_memberships
|
||||||
|
for each row
|
||||||
|
execute function public.handle_updated_at();
|
||||||
|
|
||||||
|
-- Set updated_at on eb_specifications updates
|
||||||
|
create trigger trigger_eb_specifications_updated_at
|
||||||
|
before update on public.eb_specifications
|
||||||
|
for each row
|
||||||
|
execute function public.handle_updated_at();
|
||||||
|
|
||||||
|
-- Set updated_at on eb_exams updates
|
||||||
|
create trigger trigger_eb_exams_updated_at
|
||||||
|
before update on public.eb_exams
|
||||||
|
for each row
|
||||||
|
execute function public.handle_updated_at();
|
||||||
@ -1,71 +0,0 @@
|
|||||||
-- 001_gais_seed.sql
|
|
||||||
-- GAIS (Get Information About Schools) reference tables
|
|
||||||
-- Source: Edubase open data, https://www.get-information-schools.service.gov.uk/
|
|
||||||
-- Apply once to the Supabase Postgres instance via the SQL editor.
|
|
||||||
|
|
||||||
-- ─── Local Authorities ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS gais_local_authorities (
|
|
||||||
code TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ─── Schools ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS gais_schools (
|
|
||||||
urn TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
status TEXT, -- Open | Closed | Proposed to Open
|
|
||||||
phase TEXT, -- Primary | Secondary | 16 plus | etc.
|
|
||||||
type TEXT, -- Voluntary aided school | Academy | etc.
|
|
||||||
type_group TEXT, -- Local authority maintained | Independent | etc.
|
|
||||||
street TEXT,
|
|
||||||
locality TEXT,
|
|
||||||
town TEXT,
|
|
||||||
county TEXT,
|
|
||||||
postcode TEXT,
|
|
||||||
website TEXT,
|
|
||||||
telephone TEXT,
|
|
||||||
head_title TEXT,
|
|
||||||
head_first_name TEXT,
|
|
||||||
head_last_name TEXT,
|
|
||||||
la_code TEXT REFERENCES gais_local_authorities(code),
|
|
||||||
la_name TEXT,
|
|
||||||
number_of_pupils INTEGER,
|
|
||||||
open_date DATE,
|
|
||||||
close_date DATE,
|
|
||||||
gender TEXT, -- Mixed | Girls | Boys
|
|
||||||
religious_character TEXT,
|
|
||||||
region TEXT, -- Government Office Region
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ─── Indexes ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
-- Full-text search on name + town + postcode
|
|
||||||
CREATE INDEX IF NOT EXISTS gais_schools_name_fts
|
|
||||||
ON gais_schools USING gin(to_tsvector('english', coalesce(name, '') || ' ' || coalesce(town, '') || ' ' || coalesce(postcode, '')));
|
|
||||||
|
|
||||||
-- Trigram index for ILIKE search (pg_trgm extension required)
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
||||||
CREATE INDEX IF NOT EXISTS gais_schools_name_trgm ON gais_schools USING gin(name gin_trgm_ops);
|
|
||||||
CREATE INDEX IF NOT EXISTS gais_schools_town_trgm ON gais_schools USING gin(town gin_trgm_ops);
|
|
||||||
CREATE INDEX IF NOT EXISTS gais_schools_postcode_trgm ON gais_schools USING gin(postcode gin_trgm_ops);
|
|
||||||
|
|
||||||
-- Status and LA for filtered queries
|
|
||||||
CREATE INDEX IF NOT EXISTS gais_schools_status ON gais_schools(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS gais_schools_la_code ON gais_schools(la_code);
|
|
||||||
|
|
||||||
-- ─── RLS ─────────────────────────────────────────────────────────────────────
|
|
||||||
-- Public read (these are open-data reference tables).
|
|
||||||
-- Writes are only via service-role (admin imports / seed scripts).
|
|
||||||
|
|
||||||
ALTER TABLE gais_local_authorities ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE gais_schools ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY "Public read gais_local_authorities"
|
|
||||||
ON gais_local_authorities FOR SELECT USING (true);
|
|
||||||
|
|
||||||
CREATE POLICY "Public read gais_schools"
|
|
||||||
ON gais_schools FOR SELECT USING (true);
|
|
||||||
@ -1,763 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Classroom Copilot — Application Schema
|
|
||||||
-- Migration 002: All application tables (non-GAIS)
|
|
||||||
-- Run after: 001_gais_seed.sql
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- Extensions
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 1. Core user & school tables
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- 1.1 Profiles — mirrors auth.users, extended user data
|
|
||||||
CREATE TABLE IF NOT EXISTS profiles (
|
|
||||||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
email TEXT NOT NULL,
|
|
||||||
user_type TEXT NOT NULL CHECK (user_type IN ('teacher','student','admin')),
|
|
||||||
username TEXT NOT NULL UNIQUE,
|
|
||||||
full_name TEXT,
|
|
||||||
display_name TEXT,
|
|
||||||
school_id UUID, -- FK to institutes added below (circular avoidance)
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}',
|
|
||||||
neo4j_sync_status TEXT DEFAULT 'pending', -- tracks Neo4j knowledge-graph sync
|
|
||||||
neo4j_synced_at TIMESTAMPTZ,
|
|
||||||
last_login TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 1.2 Admin profiles — separate table for system-level admins
|
|
||||||
CREATE TABLE IF NOT EXISTS admin_profiles (
|
|
||||||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
email TEXT NOT NULL,
|
|
||||||
display_name TEXT,
|
|
||||||
admin_role TEXT NOT NULL DEFAULT 'admin',
|
|
||||||
is_super_admin BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 1.3 Institutes (schools)
|
|
||||||
CREATE TABLE IF NOT EXISTS institutes (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
urn TEXT UNIQUE,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
address JSONB NOT NULL DEFAULT '{}',
|
|
||||||
website TEXT,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}',
|
|
||||||
geo_coordinates JSONB NOT NULL DEFAULT '{}',
|
|
||||||
neo4j_uuid_string TEXT,
|
|
||||||
neo4j_public_sync_status TEXT DEFAULT 'pending',
|
|
||||||
neo4j_public_sync_at TIMESTAMPTZ,
|
|
||||||
neo4j_private_sync_status TEXT DEFAULT 'not_started',
|
|
||||||
neo4j_private_sync_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Deferred FK: profiles.school_id → institutes
|
|
||||||
ALTER TABLE profiles
|
|
||||||
DROP CONSTRAINT IF EXISTS profiles_school_id_fkey;
|
|
||||||
ALTER TABLE profiles
|
|
||||||
ADD CONSTRAINT profiles_school_id_fkey
|
|
||||||
FOREIGN KEY (school_id) REFERENCES institutes(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
-- 1.4 Institute memberships
|
|
||||||
CREATE TABLE IF NOT EXISTS institute_memberships (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
|
|
||||||
role TEXT NOT NULL CHECK (role IN ('school_admin','teacher','student')),
|
|
||||||
tldraw_preferences JSONB NOT NULL DEFAULT '{}',
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (profile_id, institute_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 1.5 Institute membership requests (teacher invite / student join flow)
|
|
||||||
CREATE TABLE IF NOT EXISTS institute_membership_requests (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
|
|
||||||
requested_role TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 2. TLDraw whiteboard rooms
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS whiteboard_rooms (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
institute_id UUID REFERENCES institutes(id) ON DELETE SET NULL,
|
|
||||||
name TEXT NOT NULL DEFAULT 'My Workspace',
|
|
||||||
context_type TEXT NOT NULL DEFAULT 'profile',
|
|
||||||
context_id TEXT,
|
|
||||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
storage_path TEXT,
|
|
||||||
neo4j_node_id TEXT,
|
|
||||||
neo4j_db_name TEXT,
|
|
||||||
node_type TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 3. File cabinet system
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- 3.1 Cabinets — top-level containers owned by a user
|
|
||||||
CREATE TABLE IF NOT EXISTS file_cabinets (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 3.2 Files — records for files stored in Supabase Storage
|
|
||||||
CREATE TABLE IF NOT EXISTS files (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
cabinet_id UUID NOT NULL REFERENCES file_cabinets(id) ON DELETE CASCADE,
|
|
||||||
uploaded_by UUID REFERENCES profiles(id) ON DELETE SET NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
bucket TEXT NOT NULL DEFAULT 'cc.users',
|
|
||||||
mime_type TEXT,
|
|
||||||
size_bytes BIGINT,
|
|
||||||
size TEXT,
|
|
||||||
category TEXT,
|
|
||||||
source TEXT DEFAULT 'uploader-web',
|
|
||||||
is_directory BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
parent_directory_id UUID REFERENCES files(id) ON DELETE SET NULL,
|
|
||||||
relative_path TEXT,
|
|
||||||
directory_manifest JSONB,
|
|
||||||
upload_session_id UUID,
|
|
||||||
processing_status TEXT NOT NULL DEFAULT 'uploaded',
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 3.3 Cabinet memberships — share a cabinet with other users
|
|
||||||
CREATE TABLE IF NOT EXISTS cabinet_memberships (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
cabinet_id UUID NOT NULL REFERENCES file_cabinets(id) ON DELETE CASCADE,
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('viewer','editor','owner')),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (cabinet_id, profile_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 3.4 Document artefacts — processed outputs from files (Docling, Tika, etc.)
|
|
||||||
CREATE TABLE IF NOT EXISTS document_artefacts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
rel_path TEXT NOT NULL,
|
|
||||||
page_number INTEGER NOT NULL DEFAULT 0,
|
|
||||||
chunk_index INTEGER,
|
|
||||||
size_tag TEXT,
|
|
||||||
language TEXT,
|
|
||||||
extra JSONB,
|
|
||||||
status TEXT NOT NULL DEFAULT 'completed',
|
|
||||||
started_at TIMESTAMPTZ DEFAULT now(),
|
|
||||||
completed_at TIMESTAMPTZ,
|
|
||||||
error_message TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 4. Knowledge banks (brains) — Phase G: RAG over file collections
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS brains (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
purpose TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS brain_files (
|
|
||||||
brain_id UUID NOT NULL REFERENCES brains(id) ON DELETE CASCADE,
|
|
||||||
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (brain_id, file_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 5. Class system
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- 5.1 Classes
|
|
||||||
CREATE TABLE IF NOT EXISTS classes (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR NOT NULL,
|
|
||||||
class_code TEXT, -- MIS identifier e.g. '9YO/Bi', '10Da'
|
|
||||||
subject VARCHAR,
|
|
||||||
key_stage TEXT, -- '3', '4', '5'
|
|
||||||
year_group VARCHAR,
|
|
||||||
academic_year VARCHAR,
|
|
||||||
description TEXT,
|
|
||||||
type VARCHAR NOT NULL DEFAULT 'standard',
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
created_by UUID NOT NULL REFERENCES profiles(id),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 5.2 Class teachers
|
|
||||||
CREATE TABLE IF NOT EXISTS class_teachers (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
|
||||||
teacher_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
is_primary BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
can_edit BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
assigned_by UUID REFERENCES profiles(id),
|
|
||||||
UNIQUE (class_id, teacher_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 5.3 Class students
|
|
||||||
CREATE TABLE IF NOT EXISTS class_students (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
|
||||||
student_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
status VARCHAR NOT NULL DEFAULT 'active' CHECK (status IN ('active','inactive','pending')),
|
|
||||||
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
enrolled_by UUID REFERENCES profiles(id),
|
|
||||||
UNIQUE (class_id, student_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 5.4 Enrollment requests — student self-enrollment flow (Phase D)
|
|
||||||
CREATE TABLE IF NOT EXISTS enrollment_requests (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
|
||||||
student_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
status VARCHAR NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
|
|
||||||
request_message TEXT,
|
|
||||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
responded_at TIMESTAMPTZ,
|
|
||||||
responded_by UUID REFERENCES profiles(id),
|
|
||||||
response_message TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 6. Curriculum reference (flat Supabase lookup — full graph in Neo4j Phase G)
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- 6.0 Curriculum topics — importable from curriculum.xlsx, referenced by planned_lessons
|
|
||||||
CREATE TABLE IF NOT EXISTS curriculum_topics (
|
|
||||||
id TEXT PRIMARY KEY, -- e.g. '7B1', '10P10' — matches xlsx TopicID
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
subject TEXT,
|
|
||||||
key_stage TEXT, -- '3', '4', '5'
|
|
||||||
year_group TEXT,
|
|
||||||
topic_type TEXT, -- 'Standard', 'Assessment', etc.
|
|
||||||
total_lessons INTEGER,
|
|
||||||
department TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 7. Lesson planning — Phase C
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- 7.1 Planned lessons — teacher-authored lesson plans
|
|
||||||
CREATE TABLE IF NOT EXISTS planned_lessons (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
created_by UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id),
|
|
||||||
class_id UUID REFERENCES classes(id) ON DELETE SET NULL,
|
|
||||||
whiteboard_room_id UUID REFERENCES whiteboard_rooms(id) ON DELETE SET NULL,
|
|
||||||
topic_code TEXT REFERENCES curriculum_topics(id) ON DELETE SET NULL,
|
|
||||||
timetable_period_id TEXT, -- Neo4j period node reference (e.g. 'AMon1')
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
subject TEXT,
|
|
||||||
year_group TEXT,
|
|
||||||
estimated_duration_minutes INTEGER,
|
|
||||||
objectives JSONB NOT NULL DEFAULT '[]',
|
|
||||||
activities JSONB NOT NULL DEFAULT '[]',
|
|
||||||
status TEXT NOT NULL DEFAULT 'draft'
|
|
||||||
CHECK (status IN ('draft','ready','archived')),
|
|
||||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 6.2 Lesson collaborators — co-planning: additional teachers on a lesson
|
|
||||||
CREATE TABLE IF NOT EXISTS lesson_collaborators (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
planned_lesson_id UUID NOT NULL REFERENCES planned_lessons(id) ON DELETE CASCADE,
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
can_edit BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (planned_lesson_id, profile_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 6.3 Lesson deliveries — records of when a planned lesson is actually taught
|
|
||||||
CREATE TABLE IF NOT EXISTS lesson_deliveries (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
planned_lesson_id UUID REFERENCES planned_lessons(id) ON DELETE SET NULL,
|
|
||||||
delivered_by UUID NOT NULL REFERENCES profiles(id),
|
|
||||||
class_id UUID REFERENCES classes(id) ON DELETE SET NULL,
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id),
|
|
||||||
whiteboard_room_id UUID REFERENCES whiteboard_rooms(id) ON DELETE SET NULL,
|
|
||||||
transcription_session_id UUID, -- FK to transcription_sessions added after CIS tables
|
|
||||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
ended_at TIMESTAMPTZ,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 8. Exam board reference (Phase F)
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS eb_specifications (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
spec_code TEXT UNIQUE,
|
|
||||||
exam_board_code TEXT,
|
|
||||||
award_code TEXT,
|
|
||||||
subject_code TEXT,
|
|
||||||
first_teach TEXT,
|
|
||||||
spec_ver TEXT,
|
|
||||||
storage_loc TEXT,
|
|
||||||
doc_type TEXT,
|
|
||||||
doc_details JSONB NOT NULL DEFAULT '{}',
|
|
||||||
docling_docs JSONB NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS eb_exams (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
exam_code TEXT,
|
|
||||||
spec_code TEXT REFERENCES eb_specifications(spec_code),
|
|
||||||
paper_code TEXT,
|
|
||||||
tier TEXT,
|
|
||||||
session TEXT,
|
|
||||||
type_code TEXT,
|
|
||||||
storage_loc TEXT,
|
|
||||||
doc_type TEXT,
|
|
||||||
doc_details JSONB NOT NULL DEFAULT '{}',
|
|
||||||
docling_docs JSONB NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 9. CIS: Transcription & Canvas event system
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- 8.1 Transcription sessions
|
|
||||||
CREATE TABLE IF NOT EXISTS transcription_sessions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
title TEXT,
|
|
||||||
canvas_type TEXT DEFAULT 'tldraw',
|
|
||||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
ended_at TIMESTAMPTZ,
|
|
||||||
duration_seconds INTEGER,
|
|
||||||
timetable_period_id TEXT,
|
|
||||||
timetable_event_type TEXT,
|
|
||||||
timetable_event_label TEXT,
|
|
||||||
auto_tagged BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
llm_provider TEXT,
|
|
||||||
llm_model TEXT,
|
|
||||||
word_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
segment_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}',
|
|
||||||
deleted_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 8.2 Transcription segments
|
|
||||||
CREATE TABLE IF NOT EXISTS transcription_segments (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
session_id UUID NOT NULL REFERENCES transcription_sessions(id) ON DELETE CASCADE,
|
|
||||||
sequence_index INTEGER NOT NULL,
|
|
||||||
text TEXT NOT NULL,
|
|
||||||
start_seconds REAL NOT NULL DEFAULT 0,
|
|
||||||
end_seconds REAL NOT NULL DEFAULT 0,
|
|
||||||
is_final BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
speaker_label TEXT,
|
|
||||||
keyword_matches TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 8.3 Canvas events (TLDraw interactions during a session)
|
|
||||||
CREATE TABLE IF NOT EXISTS canvas_events (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
session_id UUID REFERENCES transcription_sessions(id) ON DELETE CASCADE,
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
session_elapsed_seconds REAL,
|
|
||||||
event_type TEXT NOT NULL,
|
|
||||||
event_payload JSONB NOT NULL DEFAULT '{}',
|
|
||||||
canvas_snapshot_url TEXT,
|
|
||||||
tldraw_page_id TEXT,
|
|
||||||
tldraw_shape_ids TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 8.4 AI-generated summaries
|
|
||||||
CREATE TABLE IF NOT EXISTS transcription_summaries (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
session_id UUID NOT NULL REFERENCES transcription_sessions(id) ON DELETE CASCADE,
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
summary_type TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
prompt_used TEXT,
|
|
||||||
llm_provider TEXT,
|
|
||||||
llm_model TEXT,
|
|
||||||
input_tokens INTEGER,
|
|
||||||
output_tokens INTEGER,
|
|
||||||
segment_range_start INTEGER,
|
|
||||||
segment_range_end INTEGER,
|
|
||||||
canvas_snapshot_urls TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 8.5 Keyword watch list
|
|
||||||
CREATE TABLE IF NOT EXISTS keyword_watches (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
keyword TEXT NOT NULL,
|
|
||||||
match_type TEXT NOT NULL DEFAULT 'contains',
|
|
||||||
action TEXT NOT NULL DEFAULT 'notify',
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (user_id, keyword)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 8.6 Keyword events
|
|
||||||
CREATE TABLE IF NOT EXISTS keyword_events (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
session_id UUID NOT NULL REFERENCES transcription_sessions(id) ON DELETE CASCADE,
|
|
||||||
segment_id UUID REFERENCES transcription_segments(id) ON DELETE SET NULL,
|
|
||||||
keyword_watch_id UUID REFERENCES keyword_watches(id) ON DELETE SET NULL,
|
|
||||||
keyword_text TEXT NOT NULL,
|
|
||||||
matched_in_text TEXT NOT NULL,
|
|
||||||
session_elapsed_seconds REAL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Deferred FK: lesson_deliveries.transcription_session_id
|
|
||||||
ALTER TABLE lesson_deliveries
|
|
||||||
DROP CONSTRAINT IF EXISTS lesson_deliveries_transcription_session_id_fkey;
|
|
||||||
ALTER TABLE lesson_deliveries
|
|
||||||
ADD CONSTRAINT lesson_deliveries_transcription_session_id_fkey
|
|
||||||
FOREIGN KEY (transcription_session_id)
|
|
||||||
REFERENCES transcription_sessions(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 9. Updated_at trigger
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION set_updated_at()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = now();
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
DO $$ DECLARE
|
|
||||||
t TEXT;
|
|
||||||
BEGIN
|
|
||||||
FOREACH t IN ARRAY ARRAY[
|
|
||||||
'profiles', 'admin_profiles', 'institutes',
|
|
||||||
'institute_memberships', 'institute_membership_requests',
|
|
||||||
'whiteboard_rooms', 'cabinet_memberships',
|
|
||||||
'classes', 'planned_lessons', 'lesson_deliveries',
|
|
||||||
'transcription_sessions', 'keyword_watches',
|
|
||||||
'eb_specifications', 'eb_exams'
|
|
||||||
] LOOP
|
|
||||||
EXECUTE format(
|
|
||||||
'DROP TRIGGER IF EXISTS trg_updated_at ON %I;
|
|
||||||
CREATE TRIGGER trg_updated_at
|
|
||||||
BEFORE UPDATE ON %I
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();',
|
|
||||||
t, t
|
|
||||||
);
|
|
||||||
END LOOP;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 10. Indexes
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- Profiles
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_profiles_school_id ON profiles(school_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_profiles_email ON profiles(email);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_profiles_username ON profiles(username);
|
|
||||||
|
|
||||||
-- Institutes
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_institutes_urn ON institutes(urn);
|
|
||||||
|
|
||||||
-- Institute memberships
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_im_profile ON institute_memberships(profile_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_im_institute ON institute_memberships(institute_id);
|
|
||||||
|
|
||||||
-- Whiteboard rooms
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_wr_user ON whiteboard_rooms(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_wr_context ON whiteboard_rooms(context_type, context_id);
|
|
||||||
|
|
||||||
-- Files
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_files_cabinet ON files(cabinet_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_files_uploaded_by ON files(uploaded_by);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_files_status ON files(processing_status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_files_parent ON files(parent_directory_id);
|
|
||||||
|
|
||||||
-- Document artefacts
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_artefacts_file ON document_artefacts(file_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_artefacts_type ON document_artefacts(type);
|
|
||||||
|
|
||||||
-- Cabinet memberships
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_cabinet_mb_profile ON cabinet_memberships(profile_id);
|
|
||||||
|
|
||||||
-- Brains
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_brains_user ON brains(user_id);
|
|
||||||
|
|
||||||
-- Curriculum topics
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ct_subject ON curriculum_topics(subject);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ct_key_stage ON curriculum_topics(key_stage);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ct_year_group ON curriculum_topics(year_group);
|
|
||||||
|
|
||||||
-- Classes
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_classes_institute ON classes(institute_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_classes_class_code ON classes(class_code);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_classes_created_by ON classes(created_by);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_class_teachers_class ON class_teachers(class_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_class_students_class ON class_students(class_id);
|
|
||||||
|
|
||||||
-- Planned lessons
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pl_created_by ON planned_lessons(created_by);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pl_institute ON planned_lessons(institute_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pl_class ON planned_lessons(class_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pl_status ON planned_lessons(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pl_topic_code ON planned_lessons(topic_code);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pl_timetable_period ON planned_lessons(timetable_period_id);
|
|
||||||
|
|
||||||
-- Lesson deliveries
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ld_delivered_by ON lesson_deliveries(delivered_by);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ld_planned_lesson ON lesson_deliveries(planned_lesson_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ld_started_at ON lesson_deliveries(started_at DESC);
|
|
||||||
|
|
||||||
-- CIS
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ts_user ON transcription_sessions(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ts_started ON transcription_sessions(started_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ts_deleted ON transcription_sessions(deleted_at) WHERE deleted_at IS NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_seg_session ON transcription_segments(session_id, sequence_index);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ce_session ON canvas_events(session_id, timestamp);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ce_user ON canvas_events(user_id, timestamp DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_kw_user ON keyword_watches(user_id) WHERE is_active = true;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ke_session ON keyword_events(session_id);
|
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 11. Row Level Security
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- Enable RLS
|
|
||||||
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE admin_profiles ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE institutes ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE institute_memberships ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE institute_membership_requests ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE whiteboard_rooms ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE file_cabinets ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE files ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE cabinet_memberships ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE document_artefacts ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE brains ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE brain_files ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE classes ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE class_teachers ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE class_students ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE enrollment_requests ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE planned_lessons ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE lesson_collaborators ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE lesson_deliveries ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE curriculum_topics ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE eb_specifications ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE eb_exams ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE transcription_sessions ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE transcription_segments ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE canvas_events ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE transcription_summaries ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE keyword_watches ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE keyword_events ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- Profiles: own row + service role full access
|
|
||||||
DROP POLICY IF EXISTS "profiles_own" ON profiles;
|
|
||||||
DROP POLICY IF EXISTS "profiles_service_role" ON profiles;
|
|
||||||
CREATE POLICY "profiles_own" ON profiles FOR ALL USING (id = auth.uid());
|
|
||||||
CREATE POLICY "profiles_service_role" ON profiles FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Admin profiles: super admins only
|
|
||||||
DROP POLICY IF EXISTS "admin_profiles_service_role" ON admin_profiles;
|
|
||||||
CREATE POLICY "admin_profiles_service_role" ON admin_profiles FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Institutes: members can read, school_admin can write, service role full access
|
|
||||||
DROP POLICY IF EXISTS "institutes_member_read" ON institutes;
|
|
||||||
DROP POLICY IF EXISTS "institutes_service_role" ON institutes;
|
|
||||||
CREATE POLICY "institutes_member_read" ON institutes FOR SELECT
|
|
||||||
USING (id IN (SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()));
|
|
||||||
CREATE POLICY "institutes_service_role" ON institutes FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Institute memberships
|
|
||||||
DROP POLICY IF EXISTS "im_own" ON institute_memberships;
|
|
||||||
DROP POLICY IF EXISTS "im_service_role" ON institute_memberships;
|
|
||||||
CREATE POLICY "im_own" ON institute_memberships FOR ALL USING (profile_id = auth.uid());
|
|
||||||
CREATE POLICY "im_service_role" ON institute_memberships FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Institute membership requests
|
|
||||||
DROP POLICY IF EXISTS "imr_own" ON institute_membership_requests;
|
|
||||||
DROP POLICY IF EXISTS "imr_service_role" ON institute_membership_requests;
|
|
||||||
CREATE POLICY "imr_own" ON institute_membership_requests FOR ALL USING (profile_id = auth.uid());
|
|
||||||
CREATE POLICY "imr_service_role" ON institute_membership_requests FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Whiteboard rooms: own rooms
|
|
||||||
DROP POLICY IF EXISTS "wr_own" ON whiteboard_rooms;
|
|
||||||
DROP POLICY IF EXISTS "wr_service_role" ON whiteboard_rooms;
|
|
||||||
CREATE POLICY "wr_own" ON whiteboard_rooms FOR ALL USING (user_id = auth.uid());
|
|
||||||
CREATE POLICY "wr_service_role" ON whiteboard_rooms FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- File cabinets: own cabinets
|
|
||||||
DROP POLICY IF EXISTS "fc_own" ON file_cabinets;
|
|
||||||
DROP POLICY IF EXISTS "fc_service_role" ON file_cabinets;
|
|
||||||
CREATE POLICY "fc_own" ON file_cabinets FOR ALL USING (user_id = auth.uid());
|
|
||||||
CREATE POLICY "fc_service_role" ON file_cabinets FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Files: own via cabinet
|
|
||||||
DROP POLICY IF EXISTS "files_own" ON files;
|
|
||||||
DROP POLICY IF EXISTS "files_service_role" ON files;
|
|
||||||
CREATE POLICY "files_own" ON files FOR ALL
|
|
||||||
USING (cabinet_id IN (SELECT id FROM file_cabinets WHERE user_id = auth.uid()));
|
|
||||||
CREATE POLICY "files_service_role" ON files FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Cabinet memberships
|
|
||||||
DROP POLICY IF EXISTS "cm_own" ON cabinet_memberships;
|
|
||||||
DROP POLICY IF EXISTS "cm_service_role" ON cabinet_memberships;
|
|
||||||
CREATE POLICY "cm_own" ON cabinet_memberships FOR ALL USING (profile_id = auth.uid());
|
|
||||||
CREATE POLICY "cm_service_role" ON cabinet_memberships FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Document artefacts: via file ownership
|
|
||||||
DROP POLICY IF EXISTS "da_own" ON document_artefacts;
|
|
||||||
DROP POLICY IF EXISTS "da_service_role" ON document_artefacts;
|
|
||||||
CREATE POLICY "da_own" ON document_artefacts FOR ALL
|
|
||||||
USING (file_id IN (
|
|
||||||
SELECT f.id FROM files f
|
|
||||||
JOIN file_cabinets fc ON fc.id = f.cabinet_id
|
|
||||||
WHERE fc.user_id = auth.uid()
|
|
||||||
));
|
|
||||||
CREATE POLICY "da_service_role" ON document_artefacts FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Brains
|
|
||||||
DROP POLICY IF EXISTS "brains_own" ON brains;
|
|
||||||
DROP POLICY IF EXISTS "brains_service_role" ON brains;
|
|
||||||
CREATE POLICY "brains_own" ON brains FOR ALL USING (user_id = auth.uid());
|
|
||||||
CREATE POLICY "brains_service_role" ON brains FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Brain files
|
|
||||||
DROP POLICY IF EXISTS "brain_files_own" ON brain_files;
|
|
||||||
DROP POLICY IF EXISTS "brain_files_service_role" ON brain_files;
|
|
||||||
CREATE POLICY "brain_files_own" ON brain_files FOR ALL
|
|
||||||
USING (brain_id IN (SELECT id FROM brains WHERE user_id = auth.uid()));
|
|
||||||
CREATE POLICY "brain_files_service_role" ON brain_files FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Classes: members of institute can read; teachers can write
|
|
||||||
DROP POLICY IF EXISTS "classes_institute_read" ON classes;
|
|
||||||
DROP POLICY IF EXISTS "classes_service_role" ON classes;
|
|
||||||
CREATE POLICY "classes_institute_read" ON classes FOR SELECT
|
|
||||||
USING (institute_id IN (SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()));
|
|
||||||
CREATE POLICY "classes_service_role" ON classes FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Class teachers / students
|
|
||||||
DROP POLICY IF EXISTS "ct_service_role" ON class_teachers;
|
|
||||||
DROP POLICY IF EXISTS "cs_service_role" ON class_students;
|
|
||||||
CREATE POLICY "ct_service_role" ON class_teachers FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
CREATE POLICY "cs_service_role" ON class_students FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Enrollment requests
|
|
||||||
DROP POLICY IF EXISTS "er_own" ON enrollment_requests;
|
|
||||||
DROP POLICY IF EXISTS "er_service_role" ON enrollment_requests;
|
|
||||||
CREATE POLICY "er_own" ON enrollment_requests FOR ALL USING (student_id = auth.uid());
|
|
||||||
CREATE POLICY "er_service_role" ON enrollment_requests FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Planned lessons
|
|
||||||
DROP POLICY IF EXISTS "pl_own" ON planned_lessons;
|
|
||||||
DROP POLICY IF EXISTS "pl_collab_read" ON planned_lessons;
|
|
||||||
DROP POLICY IF EXISTS "pl_service_role" ON planned_lessons;
|
|
||||||
CREATE POLICY "pl_own" ON planned_lessons FOR ALL USING (created_by = auth.uid());
|
|
||||||
CREATE POLICY "pl_collab_read" ON planned_lessons FOR SELECT
|
|
||||||
USING (id IN (SELECT planned_lesson_id FROM lesson_collaborators WHERE profile_id = auth.uid()));
|
|
||||||
CREATE POLICY "pl_service_role" ON planned_lessons FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Lesson collaborators
|
|
||||||
DROP POLICY IF EXISTS "lc_own" ON lesson_collaborators;
|
|
||||||
DROP POLICY IF EXISTS "lc_service_role" ON lesson_collaborators;
|
|
||||||
CREATE POLICY "lc_own" ON lesson_collaborators FOR ALL USING (profile_id = auth.uid());
|
|
||||||
CREATE POLICY "lc_service_role" ON lesson_collaborators FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Lesson deliveries
|
|
||||||
DROP POLICY IF EXISTS "ld_own" ON lesson_deliveries;
|
|
||||||
DROP POLICY IF EXISTS "ld_service_role" ON lesson_deliveries;
|
|
||||||
CREATE POLICY "ld_own" ON lesson_deliveries FOR ALL USING (delivered_by = auth.uid());
|
|
||||||
CREATE POLICY "ld_service_role" ON lesson_deliveries FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Curriculum topics: public read, service role write
|
|
||||||
DROP POLICY IF EXISTS "ct_public_read" ON curriculum_topics;
|
|
||||||
DROP POLICY IF EXISTS "ct_service_role" ON curriculum_topics;
|
|
||||||
CREATE POLICY "ct_public_read" ON curriculum_topics FOR SELECT USING (true);
|
|
||||||
CREATE POLICY "ct_service_role" ON curriculum_topics FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- Exam boards: public read
|
|
||||||
DROP POLICY IF EXISTS "eb_spec_public_read" ON eb_specifications;
|
|
||||||
DROP POLICY IF EXISTS "eb_spec_service_role" ON eb_specifications;
|
|
||||||
DROP POLICY IF EXISTS "eb_exam_public_read" ON eb_exams;
|
|
||||||
DROP POLICY IF EXISTS "eb_exam_service_role" ON eb_exams;
|
|
||||||
CREATE POLICY "eb_spec_public_read" ON eb_specifications FOR SELECT USING (true);
|
|
||||||
CREATE POLICY "eb_spec_service_role" ON eb_specifications FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
CREATE POLICY "eb_exam_public_read" ON eb_exams FOR SELECT USING (true);
|
|
||||||
CREATE POLICY "eb_exam_service_role" ON eb_exams FOR ALL USING (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- CIS: users own their sessions
|
|
||||||
DROP POLICY IF EXISTS "ts_own" ON transcription_sessions;
|
|
||||||
DROP POLICY IF EXISTS "seg_own" ON transcription_segments;
|
|
||||||
DROP POLICY IF EXISTS "ce_own" ON canvas_events;
|
|
||||||
DROP POLICY IF EXISTS "sum_own" ON transcription_summaries;
|
|
||||||
DROP POLICY IF EXISTS "kw_own" ON keyword_watches;
|
|
||||||
DROP POLICY IF EXISTS "ke_own" ON keyword_events;
|
|
||||||
CREATE POLICY "ts_own" ON transcription_sessions FOR ALL USING (user_id = auth.uid());
|
|
||||||
CREATE POLICY "seg_own" ON transcription_segments FOR ALL
|
|
||||||
USING (session_id IN (SELECT id FROM transcription_sessions WHERE user_id = auth.uid()));
|
|
||||||
CREATE POLICY "ce_own" ON canvas_events FOR ALL USING (user_id = auth.uid());
|
|
||||||
CREATE POLICY "sum_own" ON transcription_summaries FOR ALL USING (user_id = auth.uid());
|
|
||||||
CREATE POLICY "kw_own" ON keyword_watches FOR ALL USING (user_id = auth.uid());
|
|
||||||
CREATE POLICY "ke_own" ON keyword_events FOR ALL
|
|
||||||
USING (session_id IN (SELECT id FROM transcription_sessions WHERE user_id = auth.uid()));
|
|
||||||
191
volumes/db/cc/62-functions-triggers.sql
Normal file
191
volumes/db/cc/62-functions-triggers.sql
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
--[ 8. Auth Functions ]--
|
||||||
|
-- Create a secure function to check admin status
|
||||||
|
create or replace function public.is_admin()
|
||||||
|
returns boolean as $$
|
||||||
|
select coalesce(
|
||||||
|
(select true
|
||||||
|
from public.profiles
|
||||||
|
where id = auth.uid()
|
||||||
|
and user_type = 'admin'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
$$ language sql security definer;
|
||||||
|
|
||||||
|
-- Create a secure function to check super admin status
|
||||||
|
create or replace function public.is_super_admin()
|
||||||
|
returns boolean as $$
|
||||||
|
select coalesce(
|
||||||
|
(select true
|
||||||
|
from public.profiles
|
||||||
|
where id = auth.uid()
|
||||||
|
and user_type = 'admin'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
$$ language sql security definer;
|
||||||
|
|
||||||
|
-- Create public wrapper functions
|
||||||
|
-- Note: These are now the main implementation functions, not wrappers
|
||||||
|
-- The original auth schema functions have been moved to public schema
|
||||||
|
|
||||||
|
-- Grant execute permissions
|
||||||
|
grant execute on function public.is_admin to authenticated;
|
||||||
|
grant execute on function public.is_super_admin to authenticated;
|
||||||
|
|
||||||
|
-- Initial admin setup function
|
||||||
|
create or replace function public.setup_initial_admin(admin_email text)
|
||||||
|
returns json
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
result json;
|
||||||
|
begin
|
||||||
|
-- Only allow this to run as service role or superuser
|
||||||
|
if not (
|
||||||
|
current_user = 'service_role'
|
||||||
|
or exists (
|
||||||
|
select 1 from pg_roles
|
||||||
|
where rolname = current_user
|
||||||
|
and rolsuper
|
||||||
|
)
|
||||||
|
) then
|
||||||
|
raise exception 'Must be run as service_role or superuser';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Update user_type and username for admin
|
||||||
|
update public.profiles
|
||||||
|
set user_type = 'admin',
|
||||||
|
username = coalesce(username, 'superadmin'),
|
||||||
|
display_name = coalesce(display_name, 'Super Admin')
|
||||||
|
where email = admin_email
|
||||||
|
returning json_build_object(
|
||||||
|
'id', id,
|
||||||
|
'email', email,
|
||||||
|
'user_type', user_type,
|
||||||
|
'username', username,
|
||||||
|
'display_name', display_name
|
||||||
|
) into result;
|
||||||
|
|
||||||
|
if result is null then
|
||||||
|
raise exception 'Admin user with email % not found', admin_email;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Grant execute permissions
|
||||||
|
revoke execute on function public.setup_initial_admin from public;
|
||||||
|
grant execute on function public.setup_initial_admin to authenticated, service_role;
|
||||||
|
|
||||||
|
-- Create RPC wrapper for REST API access
|
||||||
|
create or replace function rpc.setup_initial_admin(admin_email text)
|
||||||
|
returns json
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
return public.setup_initial_admin(admin_email);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Grant execute permissions for RPC wrapper
|
||||||
|
grant execute on function rpc.setup_initial_admin to authenticated, service_role;
|
||||||
|
|
||||||
|
--[ 9. Utility Functions ]--
|
||||||
|
-- Check if database is ready
|
||||||
|
create or replace function check_db_ready()
|
||||||
|
returns boolean
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
-- Check if essential schemas exist
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from information_schema.schemata
|
||||||
|
where schema_name in ('auth', 'storage', 'public')
|
||||||
|
) then
|
||||||
|
return false;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Check if essential tables exist
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from information_schema.tables
|
||||||
|
where table_schema = 'auth'
|
||||||
|
and table_name = 'users'
|
||||||
|
) then
|
||||||
|
return false;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Check if RLS is enabled on public.profiles
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from pg_tables
|
||||||
|
where schemaname = 'public'
|
||||||
|
and tablename = 'profiles'
|
||||||
|
and rowsecurity = true
|
||||||
|
) then
|
||||||
|
return false;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Grant execute permission
|
||||||
|
grant execute on function check_db_ready to anon, authenticated, service_role;
|
||||||
|
|
||||||
|
-- Function to handle new user registration
|
||||||
|
create or replace function public.handle_new_user()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
default_user_type text := 'email_student';
|
||||||
|
default_username text;
|
||||||
|
begin
|
||||||
|
-- Generate username from email
|
||||||
|
default_username := split_part(new.email, '@', 1);
|
||||||
|
|
||||||
|
insert into public.profiles (
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
user_type,
|
||||||
|
username,
|
||||||
|
display_name
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
new.email,
|
||||||
|
coalesce(new.raw_user_meta_data->>'user_type', default_user_type),
|
||||||
|
coalesce(new.raw_user_meta_data->>'username', default_username),
|
||||||
|
coalesce(new.raw_user_meta_data->>'display_name', default_username)
|
||||||
|
);
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Trigger for new user creation
|
||||||
|
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 procedure public.handle_new_user();
|
||||||
|
|
||||||
|
--[ 11. Database Triggers ]--
|
||||||
|
drop trigger if exists handle_profiles_updated_at on public.profiles;
|
||||||
|
create trigger handle_profiles_updated_at
|
||||||
|
before update on public.profiles
|
||||||
|
for each row execute function public.handle_updated_at();
|
||||||
|
|
||||||
|
drop trigger if exists handle_institute_memberships_updated_at on public.institute_memberships;
|
||||||
|
create trigger handle_institute_memberships_updated_at
|
||||||
|
before update on public.institute_memberships
|
||||||
|
for each row execute function public.handle_updated_at();
|
||||||
|
|
||||||
|
drop trigger if exists handle_membership_requests_updated_at on public.institute_membership_requests;
|
||||||
|
create trigger handle_membership_requests_updated_at
|
||||||
|
before update on public.institute_membership_requests
|
||||||
|
for each row execute function public.handle_updated_at();
|
||||||
@ -1,225 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Classroom Copilot — Academic Calendar Source of Truth
|
|
||||||
-- Migration 003: Supabase-backed academic calendar & timetable tables
|
|
||||||
-- Run after: 002_schema.sql
|
|
||||||
--
|
|
||||||
-- Design: Supabase is the source of truth for all editable calendar
|
|
||||||
-- and timetable data. Neo4j is a derived graph rebuilt from these tables.
|
|
||||||
-- All tables include neo4j_node_id to track the corresponding Neo4j uuid_string.
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- ─── 1. school_timetables ────────────────────────────────────────────────────
|
|
||||||
-- One row per academic year configuration per school.
|
|
||||||
-- periods_template JSONB stores the period definitions (code, name, times, type).
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS school_timetables (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
|
|
||||||
year_label TEXT NOT NULL, -- e.g. '2025-2026'
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE NOT NULL,
|
|
||||||
periods_template JSONB NOT NULL DEFAULT '[]',
|
|
||||||
neo4j_node_id TEXT, -- SchoolTimetable.uuid_string in Neo4j
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (institute_id, year_label)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ─── 2. academic_years ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS academic_years (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
school_timetable_id UUID NOT NULL REFERENCES school_timetables(id) ON DELETE CASCADE,
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
|
|
||||||
year_label TEXT NOT NULL, -- '2025-2026'
|
|
||||||
neo4j_node_id TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (school_timetable_id, year_label)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ─── 3. academic_terms ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS academic_terms (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
academic_year_id UUID NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
|
|
||||||
term_name TEXT NOT NULL,
|
|
||||||
term_number INTEGER NOT NULL,
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE NOT NULL,
|
|
||||||
neo4j_node_id TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (academic_year_id, term_number)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ─── 4. academic_weeks ───────────────────────────────────────────────────────
|
|
||||||
-- week_cycle 'A'|'B' for two-week timetable cycles.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS academic_weeks (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
academic_term_id UUID NOT NULL REFERENCES academic_terms(id) ON DELETE CASCADE,
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
|
|
||||||
week_number INTEGER NOT NULL, -- sequential within term
|
|
||||||
start_date DATE NOT NULL, -- Monday of this week
|
|
||||||
week_cycle TEXT NOT NULL DEFAULT 'A' CHECK (week_cycle IN ('A', 'B', '')),
|
|
||||||
neo4j_node_id TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (academic_term_id, week_number)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ─── 5. academic_days ────────────────────────────────────────────────────────
|
|
||||||
-- One row per school day (Mon–Fri within term bounds).
|
|
||||||
-- excluded_period_codes: period codes from the template that do NOT apply this day.
|
|
||||||
-- academic_day_number: sequential count of Academic-type days across the year.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS academic_days (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
academic_week_id UUID NOT NULL REFERENCES academic_weeks(id) ON DELETE CASCADE,
|
|
||||||
academic_term_id UUID NOT NULL REFERENCES academic_terms(id) ON DELETE CASCADE,
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
day_of_week TEXT NOT NULL,
|
|
||||||
day_type TEXT NOT NULL DEFAULT 'Academic'
|
|
||||||
CHECK (day_type IN ('Academic', 'Holiday', 'Staff', 'OffTimetable')),
|
|
||||||
academic_day_number INTEGER, -- null for non-Academic days
|
|
||||||
excluded_period_codes TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
neo4j_node_id TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (institute_id, date)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ─── 6. teacher_timetables ───────────────────────────────────────────────────
|
|
||||||
-- One per teacher per academic year.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS teacher_timetables (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
|
|
||||||
school_timetable_id UUID NOT NULL REFERENCES school_timetables(id) ON DELETE CASCADE,
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE NOT NULL,
|
|
||||||
neo4j_node_id TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (profile_id, school_timetable_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ─── 7. teacher_timetable_slots ──────────────────────────────────────────────
|
|
||||||
-- Weekly recurring slot assignments (day + period → subject class).
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS teacher_timetable_slots (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
teacher_timetable_id UUID NOT NULL REFERENCES teacher_timetables(id) ON DELETE CASCADE,
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
|
|
||||||
day_of_week TEXT NOT NULL,
|
|
||||||
period_code TEXT NOT NULL,
|
|
||||||
subject_class TEXT NOT NULL,
|
|
||||||
start_time TEXT NOT NULL,
|
|
||||||
end_time TEXT NOT NULL,
|
|
||||||
neo4j_node_id TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (teacher_timetable_id, day_of_week, period_code)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- Indexes
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_school_tt_institute ON school_timetables(institute_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_academic_years_tt ON academic_years(school_timetable_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_academic_years_inst ON academic_years(institute_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_academic_terms_year ON academic_terms(academic_year_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_academic_terms_inst ON academic_terms(institute_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_academic_weeks_term ON academic_weeks(academic_term_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_academic_weeks_inst ON academic_weeks(institute_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_academic_days_week ON academic_days(academic_week_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_academic_days_term ON academic_days(academic_term_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_academic_days_inst_date ON academic_days(institute_id, date);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_teacher_tt_profile ON teacher_timetables(profile_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_teacher_tt_inst ON teacher_timetables(institute_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tt_slots_timetable ON teacher_timetable_slots(teacher_timetable_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tt_slots_profile ON teacher_timetable_slots(profile_id);
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- updated_at triggers (tables that have updated_at)
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
DO $$ DECLARE t TEXT; BEGIN
|
|
||||||
FOREACH t IN ARRAY ARRAY[
|
|
||||||
'school_timetables', 'teacher_timetables', 'teacher_timetable_slots'
|
|
||||||
] LOOP
|
|
||||||
EXECUTE format(
|
|
||||||
'DROP TRIGGER IF EXISTS trg_updated_at ON %I;
|
|
||||||
CREATE TRIGGER trg_updated_at
|
|
||||||
BEFORE UPDATE ON %I
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();',
|
|
||||||
t, t
|
|
||||||
);
|
|
||||||
END LOOP;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- Row Level Security
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
ALTER TABLE school_timetables ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE academic_years ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE academic_terms ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE academic_weeks ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE academic_days ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE teacher_timetables ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE teacher_timetable_slots ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- school_timetables: institute members can read
|
|
||||||
CREATE POLICY "stt_inst_read" ON school_timetables FOR SELECT
|
|
||||||
USING (institute_id IN (
|
|
||||||
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
|
|
||||||
));
|
|
||||||
CREATE POLICY "stt_service" ON school_timetables FOR ALL
|
|
||||||
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- academic_years
|
|
||||||
CREATE POLICY "ay_inst_read" ON academic_years FOR SELECT
|
|
||||||
USING (institute_id IN (
|
|
||||||
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
|
|
||||||
));
|
|
||||||
CREATE POLICY "ay_service" ON academic_years FOR ALL
|
|
||||||
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- academic_terms
|
|
||||||
CREATE POLICY "at_inst_read" ON academic_terms FOR SELECT
|
|
||||||
USING (institute_id IN (
|
|
||||||
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
|
|
||||||
));
|
|
||||||
CREATE POLICY "at_service" ON academic_terms FOR ALL
|
|
||||||
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- academic_weeks
|
|
||||||
CREATE POLICY "aw_inst_read" ON academic_weeks FOR SELECT
|
|
||||||
USING (institute_id IN (
|
|
||||||
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
|
|
||||||
));
|
|
||||||
CREATE POLICY "aw_service" ON academic_weeks FOR ALL
|
|
||||||
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- academic_days
|
|
||||||
CREATE POLICY "ad_inst_read" ON academic_days FOR SELECT
|
|
||||||
USING (institute_id IN (
|
|
||||||
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
|
|
||||||
));
|
|
||||||
CREATE POLICY "ad_service" ON academic_days FOR ALL
|
|
||||||
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- teacher_timetables: own row
|
|
||||||
CREATE POLICY "tcht_own_read" ON teacher_timetables FOR SELECT
|
|
||||||
USING (profile_id = auth.uid());
|
|
||||||
CREATE POLICY "tcht_service" ON teacher_timetables FOR ALL
|
|
||||||
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- teacher_timetable_slots: own rows
|
|
||||||
CREATE POLICY "tchts_own_read" ON teacher_timetable_slots FOR SELECT
|
|
||||||
USING (profile_id = auth.uid());
|
|
||||||
CREATE POLICY "tchts_service" ON teacher_timetable_slots FOR ALL
|
|
||||||
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
|
|
||||||
20
volumes/db/cc/63-storage-policies.sql
Normal file
20
volumes/db/cc/63-storage-policies.sql
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
-- Storage policies configuration for Supabase
|
||||||
|
-- Note: Storage bucket policies are managed by Supabase internally
|
||||||
|
-- This file provides guidance on what should be configured
|
||||||
|
|
||||||
|
-- Storage bucket policies should be configured through:
|
||||||
|
-- 1. Supabase Dashboard > Storage > Policies
|
||||||
|
-- 2. Or via SQL with proper permissions (requires service_role or owner access)
|
||||||
|
|
||||||
|
-- Recommended policies for storage.buckets:
|
||||||
|
-- - Super admin has full access to buckets
|
||||||
|
-- - Users can create their own buckets
|
||||||
|
-- - Users can view their own buckets or public buckets
|
||||||
|
|
||||||
|
-- Recommended policies for storage.objects:
|
||||||
|
-- - Users can upload to buckets they own
|
||||||
|
-- - Users can view objects in public buckets
|
||||||
|
-- - Users can manage objects in buckets they own
|
||||||
|
|
||||||
|
-- Note: These policies require the service_role or appropriate permissions
|
||||||
|
-- to be applied to the storage schema tables
|
||||||
@ -1,266 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Classroom Copilot — Extended Schema
|
|
||||||
-- Migration 004: academic_term_breaks, academic_periods,
|
|
||||||
-- taught_lessons, invitations + ALTER extensions
|
|
||||||
-- Run after: 003_academic_calendar.sql
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- ─── admin_profiles: add updated_at trigger (table already exists) ───────────
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_trigger
|
|
||||||
WHERE tgname = 'trg_updated_at'
|
|
||||||
AND tgrelid = 'public.admin_profiles'::regclass
|
|
||||||
) THEN
|
|
||||||
EXECUTE 'CREATE TRIGGER trg_updated_at
|
|
||||||
BEFORE UPDATE ON admin_profiles
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at()';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ─── 1. academic_term_breaks ─────────────────────────────────────────────────
|
|
||||||
-- Explicit named holiday periods between terms.
|
|
||||||
-- Admins name and date these; agents can look them up or even populate them.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS academic_term_breaks (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
school_timetable_id UUID NOT NULL REFERENCES school_timetables(id) ON DELETE CASCADE,
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
|
|
||||||
break_name TEXT NOT NULL, -- e.g. "Christmas Break", "Easter Break"
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE NOT NULL,
|
|
||||||
notes TEXT,
|
|
||||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
neo4j_node_id TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (school_timetable_id, break_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ─── 2. academic_periods ─────────────────────────────────────────────────────
|
|
||||||
-- One row per period per ACADEMIC day (not holiday/staff days).
|
|
||||||
-- Instantiated at timetable setup time from school_timetables.periods_template.
|
|
||||||
-- Enables per-period notes, room assignments, and substitutions.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS academic_periods (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
academic_day_id UUID NOT NULL REFERENCES academic_days(id) ON DELETE CASCADE,
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
|
|
||||||
period_code TEXT NOT NULL, -- e.g. "1", "2", "Reg", "Break1"
|
|
||||||
period_name TEXT NOT NULL, -- e.g. "Period 1", "Registration"
|
|
||||||
period_type TEXT NOT NULL CHECK (period_type IN ('lesson','break','registration','offtimetable')),
|
|
||||||
start_time TIME NOT NULL,
|
|
||||||
end_time TIME NOT NULL,
|
|
||||||
room_code TEXT, -- default room; overridden per taught_lesson
|
|
||||||
notes TEXT,
|
|
||||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
neo4j_node_id TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (academic_day_id, period_code)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ─── 3. invitations ──────────────────────────────────────────────────────────
|
|
||||||
-- Tracks all staff and student invitations. Created by school admins.
|
|
||||||
-- API calls Supabase magic link on creation; status updated on acceptance.
|
|
||||||
-- metadata: year_group for students, subject/department for staff, etc.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS invitations (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
|
|
||||||
email TEXT NOT NULL,
|
|
||||||
role TEXT NOT NULL CHECK (role IN ('teacher','student','school_admin','department_head')),
|
|
||||||
invited_by UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
token UUID NOT NULL DEFAULT uuid_generate_v4(),
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + interval '7 days'),
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending'
|
|
||||||
CHECK (status IN ('pending','accepted','expired','cancelled')),
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Only one pending invitation per (institute, email) at a time.
|
|
||||||
-- After acceptance/expiry/cancellation a new one may be issued.
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_invitations_pending_unique
|
|
||||||
ON invitations (institute_id, email)
|
|
||||||
WHERE (status = 'pending');
|
|
||||||
|
|
||||||
-- ─── 4. taught_lessons ───────────────────────────────────────────────────────
|
|
||||||
-- One row per actual lesson occurrence, materialized from the teacher's
|
|
||||||
-- timetable slot template × matching academic_periods across the year.
|
|
||||||
-- School admin controls the frame (periods, rooms, substitutions).
|
|
||||||
-- Teachers control the content (lesson_plan, notes, tags, status).
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS taught_lessons (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
academic_period_id UUID NOT NULL REFERENCES academic_periods(id) ON DELETE CASCADE,
|
|
||||||
teacher_timetable_slot_id UUID REFERENCES teacher_timetable_slots(id) ON DELETE SET NULL,
|
|
||||||
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
|
||||||
teacher_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
|
|
||||||
-- Denormalized for fast timeline queries (avoids 4-table joins)
|
|
||||||
date DATE NOT NULL,
|
|
||||||
period_code TEXT NOT NULL,
|
|
||||||
week_cycle TEXT NOT NULL DEFAULT '',
|
|
||||||
day_of_week TEXT NOT NULL,
|
|
||||||
-- Teacher-owned content
|
|
||||||
lesson_plan JSONB NOT NULL DEFAULT '{}',
|
|
||||||
whiteboard_room_id UUID REFERENCES whiteboard_rooms(id) ON DELETE SET NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'planned'
|
|
||||||
CHECK (status IN ('planned','in_progress','completed','cancelled','substituted')),
|
|
||||||
substitute_teacher_id UUID REFERENCES profiles(id) ON DELETE SET NULL,
|
|
||||||
notes TEXT,
|
|
||||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
neo4j_node_id TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (academic_period_id, teacher_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ─── 5. Extend existing tables with notes + tags ──────────────────────────────
|
|
||||||
-- ADD COLUMN IF NOT EXISTS is idempotent — safe to re-run.
|
|
||||||
|
|
||||||
ALTER TABLE academic_terms ADD COLUMN IF NOT EXISTS notes TEXT;
|
|
||||||
ALTER TABLE academic_terms ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
|
|
||||||
|
|
||||||
ALTER TABLE academic_weeks ADD COLUMN IF NOT EXISTS notes TEXT;
|
|
||||||
ALTER TABLE academic_weeks ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
|
|
||||||
|
|
||||||
ALTER TABLE academic_days ADD COLUMN IF NOT EXISTS notes TEXT;
|
|
||||||
ALTER TABLE academic_days ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
|
|
||||||
|
|
||||||
-- week_cycle on teacher_timetable_slots: '' = applies both weeks, 'A'/'B' = specific cycle.
|
|
||||||
ALTER TABLE teacher_timetable_slots ADD COLUMN IF NOT EXISTS week_cycle TEXT NOT NULL DEFAULT '';
|
|
||||||
|
|
||||||
-- Drop old UNIQUE and replace with cycle-aware version.
|
|
||||||
-- The old constraint was (teacher_timetable_id, day_of_week, period_code).
|
|
||||||
-- PostgreSQL's generated name may differ/truncate across bootstrap history, so detect
|
|
||||||
-- the actual constraint by constrained column names instead of a stale hard-coded name.
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
old_constraint_name TEXT;
|
|
||||||
BEGIN
|
|
||||||
SELECT con.conname INTO old_constraint_name
|
|
||||||
FROM pg_constraint con
|
|
||||||
JOIN pg_class rel ON rel.oid = con.conrelid
|
|
||||||
JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
|
|
||||||
WHERE nsp.nspname = 'public'
|
|
||||||
AND rel.relname = 'teacher_timetable_slots'
|
|
||||||
AND con.contype = 'u'
|
|
||||||
AND (
|
|
||||||
SELECT array_agg(att.attname::text ORDER BY ord.ordinality)
|
|
||||||
FROM unnest(con.conkey) WITH ORDINALITY AS ord(attnum, ordinality)
|
|
||||||
JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ord.attnum
|
|
||||||
) = ARRAY['teacher_timetable_id', 'day_of_week', 'period_code']::text[]
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
IF old_constraint_name IS NOT NULL THEN
|
|
||||||
EXECUTE format('ALTER TABLE public.teacher_timetable_slots DROP CONSTRAINT %I', old_constraint_name);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_constraint
|
|
||||||
WHERE conname = 'tts_unique_slot'
|
|
||||||
AND conrelid = 'public.teacher_timetable_slots'::regclass
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.teacher_timetable_slots
|
|
||||||
ADD CONSTRAINT tts_unique_slot UNIQUE (teacher_timetable_id, week_cycle, day_of_week, period_code);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ─── 6. Indexes ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_term_breaks_tt ON academic_term_breaks(school_timetable_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_term_breaks_inst ON academic_term_breaks(institute_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ap_day ON academic_periods(academic_day_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ap_inst ON academic_periods(institute_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ap_type ON academic_periods(period_type);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_inv_inst ON invitations(institute_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_inv_email ON invitations(email);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_inv_token ON invitations(token);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_inv_status ON invitations(status);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tl_period ON taught_lessons(academic_period_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tl_teacher ON taught_lessons(teacher_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tl_class ON taught_lessons(class_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tl_inst ON taught_lessons(institute_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tl_date ON taught_lessons(date);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tl_inst_date ON taught_lessons(institute_id, date);
|
|
||||||
|
|
||||||
-- ─── 7. updated_at trigger ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
DO $$ DECLARE t TEXT; BEGIN
|
|
||||||
FOREACH t IN ARRAY ARRAY['taught_lessons'] LOOP
|
|
||||||
EXECUTE format(
|
|
||||||
'DROP TRIGGER IF EXISTS trg_updated_at ON %I;
|
|
||||||
CREATE TRIGGER trg_updated_at BEFORE UPDATE ON %I
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();',
|
|
||||||
t, t
|
|
||||||
);
|
|
||||||
END LOOP;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ─── 8. Row Level Security ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
ALTER TABLE academic_term_breaks ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE academic_periods ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE invitations ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE taught_lessons ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- ── academic_term_breaks ──────────────────────────────────────────────────────
|
|
||||||
-- Any institute member can read; all writes via service_role (API).
|
|
||||||
|
|
||||||
CREATE POLICY "atb_inst_read" ON academic_term_breaks FOR SELECT
|
|
||||||
USING (institute_id IN (
|
|
||||||
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
|
|
||||||
));
|
|
||||||
CREATE POLICY "atb_service" ON academic_term_breaks FOR ALL
|
|
||||||
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- ── academic_periods ──────────────────────────────────────────────────────────
|
|
||||||
-- Any institute member can read; all writes via service_role (API).
|
|
||||||
|
|
||||||
CREATE POLICY "ap_inst_read" ON academic_periods FOR SELECT
|
|
||||||
USING (institute_id IN (
|
|
||||||
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
|
|
||||||
));
|
|
||||||
CREATE POLICY "ap_service" ON academic_periods FOR ALL
|
|
||||||
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- ── invitations ───────────────────────────────────────────────────────────────
|
|
||||||
-- School admins and the inviter can view their school's invitations.
|
|
||||||
-- All mutations via service_role (invitations created server-side only).
|
|
||||||
|
|
||||||
CREATE POLICY "inv_admin_read" ON invitations FOR SELECT
|
|
||||||
USING (
|
|
||||||
invited_by = auth.uid()
|
|
||||||
OR institute_id IN (
|
|
||||||
SELECT institute_id FROM institute_memberships
|
|
||||||
WHERE profile_id = auth.uid()
|
|
||||||
AND role IN ('school_admin', 'department_head')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
CREATE POLICY "inv_service" ON invitations FOR ALL
|
|
||||||
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
-- ── taught_lessons ────────────────────────────────────────────────────────────
|
|
||||||
-- Teachers read their own lessons; school admins read all in their school.
|
|
||||||
-- Teachers can UPDATE their own lesson content (plan, notes, tags, status).
|
|
||||||
-- Frame changes (room, substitute) and lesson creation: service_role only.
|
|
||||||
|
|
||||||
CREATE POLICY "tl_read" ON taught_lessons FOR SELECT
|
|
||||||
USING (
|
|
||||||
teacher_id = auth.uid()
|
|
||||||
OR institute_id IN (
|
|
||||||
SELECT institute_id FROM institute_memberships
|
|
||||||
WHERE profile_id = auth.uid()
|
|
||||||
AND role IN ('school_admin', 'department_head')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
CREATE POLICY "tl_teacher_update" ON taught_lessons FOR UPDATE
|
|
||||||
USING (teacher_id = auth.uid())
|
|
||||||
WITH CHECK (teacher_id = auth.uid());
|
|
||||||
CREATE POLICY "tl_service" ON taught_lessons FOR ALL
|
|
||||||
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
|
|
||||||
20
volumes/db/cc/64-initial-admin.sql
Normal file
20
volumes/db/cc/64-initial-admin.sql
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
-- Initial admin setup for ClassroomCopilot
|
||||||
|
-- This file handles basic database setup and permissions
|
||||||
|
|
||||||
|
-- Ensure uuid-ossp extension is enabled
|
||||||
|
create extension if not exists "uuid-ossp" schema extensions;
|
||||||
|
|
||||||
|
-- Grant basic permissions to authenticated users for public schema
|
||||||
|
-- Note: These permissions are granted to allow users to work with the application
|
||||||
|
grant usage on schema public to authenticated;
|
||||||
|
grant all on all tables in schema public to authenticated;
|
||||||
|
grant all on all sequences in schema public to authenticated;
|
||||||
|
grant all on all functions in schema public to authenticated;
|
||||||
|
|
||||||
|
-- Set default privileges for future objects
|
||||||
|
alter default privileges in schema public grant all on tables to authenticated;
|
||||||
|
alter default privileges in schema public grant all on sequences to authenticated;
|
||||||
|
alter default privileges in schema public grant all on functions to authenticated;
|
||||||
|
|
||||||
|
-- Note: The setup_initial_admin function is defined in 62-functions-triggers.sql
|
||||||
|
-- and should be called with an admin email parameter when needed
|
||||||
95
volumes/db/cc/65-filesystem-augments.sql
Normal file
95
volumes/db/cc/65-filesystem-augments.sql
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
-- Files table augments and storage GC hooks
|
||||||
|
|
||||||
|
-- 1) Add columns to files if missing
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1 from information_schema.columns
|
||||||
|
where table_schema='public' and table_name='files' and column_name='uploaded_by'
|
||||||
|
) then
|
||||||
|
alter table public.files add column uploaded_by uuid references public.profiles(id);
|
||||||
|
end if;
|
||||||
|
if not exists (
|
||||||
|
select 1 from information_schema.columns
|
||||||
|
where table_schema='public' and table_name='files' and column_name='size_bytes'
|
||||||
|
) then
|
||||||
|
alter table public.files add column size_bytes bigint;
|
||||||
|
end if;
|
||||||
|
if not exists (
|
||||||
|
select 1 from information_schema.columns
|
||||||
|
where table_schema='public' and table_name='files' and column_name='source'
|
||||||
|
) then
|
||||||
|
alter table public.files add column source text default 'uploader-web';
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- 2) Unique index for cabinet/path combo
|
||||||
|
create unique index if not exists uq_files_cabinet_path on public.files(cabinet_id, path);
|
||||||
|
|
||||||
|
-- 3) Storage GC helpers (ported from neoFS with storage schema)
|
||||||
|
create or replace function public._delete_storage_objects(p_bucket text, p_path text)
|
||||||
|
returns void
|
||||||
|
language plpgsql security definer
|
||||||
|
set search_path to 'public', 'storage'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if p_bucket is null or p_path is null then
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
delete from storage.objects where bucket_id = p_bucket and name = p_path;
|
||||||
|
delete from storage.objects where bucket_id = p_bucket and name like p_path || '/%';
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public._storage_gc_sql()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql security definer
|
||||||
|
set search_path to 'public', 'storage'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
perform public._delete_storage_objects(old.bucket, old.path);
|
||||||
|
elsif tg_op = 'UPDATE' then
|
||||||
|
if (old.bucket is distinct from new.bucket) or (old.path is distinct from new.path) then
|
||||||
|
perform public._delete_storage_objects(old.bucket, old.path);
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- 4) Attach GC trigger to files bucket/path changes
|
||||||
|
drop trigger if exists trg_files_gc on public.files;
|
||||||
|
create trigger trg_files_gc
|
||||||
|
after delete or update of bucket, path on public.files
|
||||||
|
for each row execute function public._storage_gc_sql();
|
||||||
|
|
||||||
|
-- 5) Document artefacts GC: remove artefact objects from storage when rows change/delete
|
||||||
|
create or replace function public._artefact_gc_sql()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql security definer
|
||||||
|
set search_path to 'public', 'storage'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_bucket text;
|
||||||
|
begin
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
select f.bucket into v_bucket from public.files f where f.id = old.file_id;
|
||||||
|
perform public._delete_storage_objects(v_bucket, old.rel_path);
|
||||||
|
return old;
|
||||||
|
elsif tg_op = 'UPDATE' then
|
||||||
|
if (old.rel_path is distinct from new.rel_path) or (old.file_id is distinct from new.file_id) then
|
||||||
|
select f.bucket into v_bucket from public.files f where f.id = old.file_id;
|
||||||
|
perform public._delete_storage_objects(v_bucket, old.rel_path);
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_document_artefacts_gc on public.document_artefacts;
|
||||||
|
create trigger trg_document_artefacts_gc
|
||||||
|
before delete or update of file_id, rel_path on public.document_artefacts
|
||||||
|
for each row execute function public._artefact_gc_sql();
|
||||||
|
|
||||||
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Classroom Copilot — Phase C Migration
|
|
||||||
-- 003: Clean schema + lesson planning tables
|
|
||||||
-- Run after: 002_schema.sql
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 1. Drop legacy tables (Neo4j-era, replaced by Phase B/C design)
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS lesson_whiteboards CASCADE;
|
|
||||||
DROP TABLE IF EXISTS timetable_lessons CASCADE;
|
|
||||||
DROP TABLE IF EXISTS timetable_classes CASCADE;
|
|
||||||
DROP TABLE IF EXISTS timetables CASCADE;
|
|
||||||
DROP TABLE IF EXISTS lessons CASCADE;
|
|
||||||
DROP TABLE IF EXISTS audit_logs CASCADE;
|
|
||||||
DROP TABLE IF EXISTS function_logs CASCADE;
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 2. planned_lessons — drop Neo4j-era field, add course support
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- Drop stale Neo4j reference field and its index
|
|
||||||
DROP INDEX IF EXISTS idx_pl_timetable_period;
|
|
||||||
ALTER TABLE planned_lessons DROP COLUMN IF EXISTS timetable_period_id;
|
|
||||||
|
|
||||||
-- Course support (nullable — populated when courses feature ships)
|
|
||||||
ALTER TABLE planned_lessons
|
|
||||||
ADD COLUMN IF NOT EXISTS course_id UUID,
|
|
||||||
ADD COLUMN IF NOT EXISTS sequence_number INTEGER;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pl_course ON planned_lessons(course_id) WHERE course_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 3. lesson_deliveries — link to taught_lessons
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
ALTER TABLE lesson_deliveries
|
|
||||||
ADD COLUMN IF NOT EXISTS taught_lesson_id UUID
|
|
||||||
REFERENCES taught_lessons(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ld_taught_lesson ON lesson_deliveries(taught_lesson_id)
|
|
||||||
WHERE taught_lesson_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 4. Verify Phase C tables exist (idempotent — safe to re-run)
|
|
||||||
-- These are defined in 002_schema.sql; IF NOT EXISTS means
|
|
||||||
-- running 002 first is sufficient, but listed here for clarity.
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- planned_lessons, lesson_collaborators, lesson_deliveries
|
|
||||||
-- curriculum_topics
|
|
||||||
-- All present in 002_schema.sql — no action needed here.
|
|
||||||
84
volumes/db/cc/66-rls-policies.sql
Normal file
84
volumes/db/cc/66-rls-policies.sql
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
-- Enable RLS and define policies for filesystem tables
|
||||||
|
|
||||||
|
-- 1) Enable RLS
|
||||||
|
alter table if exists public.file_cabinets enable row level security;
|
||||||
|
alter table if exists public.files enable row level security;
|
||||||
|
alter table if exists public.brain_files enable row level security;
|
||||||
|
alter table if exists public.document_artefacts enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists "User can access own cabinets" on public.file_cabinets;
|
||||||
|
create policy "User can access own cabinets" on public.file_cabinets
|
||||||
|
using (user_id = auth.uid())
|
||||||
|
with check (user_id = auth.uid());
|
||||||
|
|
||||||
|
drop policy if exists "User can access files in own cabinet" on public.files;
|
||||||
|
create policy "User can access files in own cabinet" on public.files
|
||||||
|
using (exists (
|
||||||
|
select 1 from public.file_cabinets c
|
||||||
|
where c.id = files.cabinet_id and c.user_id = auth.uid()
|
||||||
|
))
|
||||||
|
with check (exists (
|
||||||
|
select 1 from public.file_cabinets c
|
||||||
|
where c.id = files.cabinet_id and c.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
drop policy if exists "User can insert files into own cabinet" on public.files;
|
||||||
|
create policy "User can insert files into own cabinet" on public.files for insert to authenticated
|
||||||
|
with check (exists (
|
||||||
|
select 1 from public.file_cabinets c
|
||||||
|
where c.id = files.cabinet_id and c.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
drop policy if exists "User can update files in own cabinet" on public.files;
|
||||||
|
create policy "User can update files in own cabinet" on public.files for update to authenticated
|
||||||
|
using (exists (
|
||||||
|
select 1 from public.file_cabinets c
|
||||||
|
where c.id = files.cabinet_id and c.user_id = auth.uid()
|
||||||
|
))
|
||||||
|
with check (exists (
|
||||||
|
select 1 from public.file_cabinets c
|
||||||
|
where c.id = files.cabinet_id and c.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
drop policy if exists "User can delete files from own cabinet" on public.files;
|
||||||
|
create policy "User can delete files from own cabinet" on public.files for delete
|
||||||
|
using (exists (
|
||||||
|
select 1 from public.file_cabinets c
|
||||||
|
where c.id = files.cabinet_id and c.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
-- 4) Brain-files: allow linking owned files to owned brains
|
||||||
|
drop policy if exists "User can link files they own to their brains" on public.brain_files;
|
||||||
|
create policy "User can link files they own to their brains" on public.brain_files
|
||||||
|
using (
|
||||||
|
exists (select 1 from public.brains b where b.id = brain_files.brain_id and b.user_id = auth.uid())
|
||||||
|
and exists (
|
||||||
|
select 1 from public.files f join public.file_cabinets c on f.cabinet_id = c.id
|
||||||
|
where f.id = brain_files.file_id and c.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with check (true);
|
||||||
|
|
||||||
|
-- 5) Document artefacts: allow reads to owners via file cabinet, writes via service_role
|
||||||
|
drop policy if exists "artefacts_read_by_owner" on public.document_artefacts;
|
||||||
|
create policy "artefacts_read_by_owner" on public.document_artefacts for select to authenticated
|
||||||
|
using (exists (
|
||||||
|
select 1 from public.files f join public.file_cabinets c on f.cabinet_id = c.id
|
||||||
|
where f.id = document_artefacts.file_id and c.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
drop policy if exists "artefacts_rw_service" on public.document_artefacts;
|
||||||
|
create policy "artefacts_rw_service" on public.document_artefacts to service_role
|
||||||
|
using (true) with check (true);
|
||||||
|
|
||||||
|
-- Allow owners to delete their artefacts (needed for cascades under RLS)
|
||||||
|
drop policy if exists "artefacts_delete_by_owner" on public.document_artefacts;
|
||||||
|
create policy "artefacts_delete_by_owner" on public.document_artefacts for delete to authenticated
|
||||||
|
using (exists (
|
||||||
|
select 1 from public.files f join public.file_cabinets c on f.cabinet_id = c.id
|
||||||
|
where f.id = document_artefacts.file_id and c.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
-- File vectors RLS and policies are defined in 67-vectors.sql after the table is created
|
||||||
|
|
||||||
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Migration 005: taught_lessons nullable class_id
|
|
||||||
-- + class_id FK on teacher_timetable_slots
|
|
||||||
-- Run after: 004_extended_schema.sql
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- taught_lessons.class_id: allow null so slots without a matched class can still materialize
|
|
||||||
ALTER TABLE taught_lessons ALTER COLUMN class_id DROP NOT NULL;
|
|
||||||
|
|
||||||
-- teacher_timetable_slots: add proper class FK alongside existing subject_class text
|
|
||||||
ALTER TABLE teacher_timetable_slots
|
|
||||||
ADD COLUMN IF NOT EXISTS class_id UUID REFERENCES classes(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tl_class_id ON taught_lessons(class_id) WHERE class_id IS NOT NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tts_class_id ON teacher_timetable_slots(class_id) WHERE class_id IS NOT NULL;
|
|
||||||
@ -1,220 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Classroom Copilot — deterministic development seed
|
|
||||||
-- Migration 067: small, repeatable fixtures for Supabase dev/staging
|
|
||||||
-- Run after: 066-taught-lessons-nullable.sql
|
|
||||||
--
|
|
||||||
-- This intentionally excludes the full GAIS open-data import. It creates a
|
|
||||||
-- compact school, users, classes, timetable, lessons, and storage bucket
|
|
||||||
-- fixtures suitable for local/dev smoke tests without sensitive live data.
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
|
||||||
|
|
||||||
-- Stable dev identities. Password for all fixture users is "devpassword".
|
|
||||||
INSERT INTO auth.users (
|
|
||||||
instance_id, id, aud, role, email, encrypted_password,
|
|
||||||
email_confirmed_at, raw_app_meta_data, raw_user_meta_data,
|
|
||||||
created_at, updated_at, confirmation_token, recovery_token, email_change_token_new, email_change
|
|
||||||
)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-000000000001', 'authenticated', 'authenticated', 'platform.admin@classroomcopilot.dev', crypt('devpassword', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}', '{"display_name":"Platform Admin","user_type":"admin"}', now(), now(), '', '', '', ''),
|
|
||||||
('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-000000000002', 'authenticated', 'authenticated', 'school.admin@classroomcopilot.dev', crypt('devpassword', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}', '{"display_name":"School Admin","user_type":"admin"}', now(), now(), '', '', '', ''),
|
|
||||||
('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-000000000011', 'authenticated', 'authenticated', 'ada.teacher@classroomcopilot.dev', crypt('devpassword', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}', '{"display_name":"Ada Teacher","user_type":"teacher"}', now(), now(), '', '', '', ''),
|
|
||||||
('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-000000000012', 'authenticated', 'authenticated', 'alan.teacher@classroomcopilot.dev', crypt('devpassword', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}', '{"display_name":"Alan Teacher","user_type":"teacher"}', now(), now(), '', '', '', ''),
|
|
||||||
('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-000000000101', 'authenticated', 'authenticated', 's1.student@classroomcopilot.dev', crypt('devpassword', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}', '{"display_name":"Student One","user_type":"student"}', now(), now(), '', '', '', ''),
|
|
||||||
('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-000000000102', 'authenticated', 'authenticated', 's2.student@classroomcopilot.dev', crypt('devpassword', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}', '{"display_name":"Student Two","user_type":"student"}', now(), now(), '', '', '', ''),
|
|
||||||
('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-000000000103', 'authenticated', 'authenticated', 's3.student@classroomcopilot.dev', crypt('devpassword', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}', '{"display_name":"Student Three","user_type":"student"}', now(), now(), '', '', '', '')
|
|
||||||
ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO public.profiles (id, email, user_type, username, full_name, display_name, school_id, metadata)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-4000-8000-000000000001', 'platform.admin@classroomcopilot.dev', 'admin', 'platform-admin', 'Platform Admin', 'Platform Admin', NULL, '{"seed":"dev","role":"platform_admin"}'),
|
|
||||||
('00000000-0000-4000-8000-000000000002', 'school.admin@classroomcopilot.dev', 'admin', 'school-admin', 'School Admin', 'School Admin', NULL, '{"seed":"dev","role":"school_admin"}'),
|
|
||||||
('00000000-0000-4000-8000-000000000011', 'ada.teacher@classroomcopilot.dev', 'teacher', 'ada-teacher', 'Ada Teacher', 'Ada Teacher', NULL, '{"seed":"dev","department":"Science"}'),
|
|
||||||
('00000000-0000-4000-8000-000000000012', 'alan.teacher@classroomcopilot.dev', 'teacher', 'alan-teacher', 'Alan Teacher', 'Alan Teacher', NULL, '{"seed":"dev","department":"Science"}'),
|
|
||||||
('00000000-0000-4000-8000-000000000101', 's1.student@classroomcopilot.dev', 'student', 'student-one', 'Student One', 'Student One', NULL, '{"seed":"dev","year_group":"9"}'),
|
|
||||||
('00000000-0000-4000-8000-000000000102', 's2.student@classroomcopilot.dev', 'student', 'student-two', 'Student Two', 'Student Two', NULL, '{"seed":"dev","year_group":"9"}'),
|
|
||||||
('00000000-0000-4000-8000-000000000103', 's3.student@classroomcopilot.dev', 'student', 'student-three', 'Student Three', 'Student Three', NULL, '{"seed":"dev","year_group":"10"}')
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
email = EXCLUDED.email,
|
|
||||||
user_type = EXCLUDED.user_type,
|
|
||||||
username = EXCLUDED.username,
|
|
||||||
full_name = EXCLUDED.full_name,
|
|
||||||
display_name = EXCLUDED.display_name,
|
|
||||||
school_id = EXCLUDED.school_id,
|
|
||||||
metadata = EXCLUDED.metadata;
|
|
||||||
|
|
||||||
INSERT INTO public.admin_profiles (id, email, display_name, admin_role, is_super_admin, metadata)
|
|
||||||
VALUES ('00000000-0000-4000-8000-000000000001', 'platform.admin@classroomcopilot.dev', 'Platform Admin', 'platform_admin', true, '{"seed":"dev"}')
|
|
||||||
ON CONFLICT (id) DO UPDATE SET is_super_admin = true, metadata = EXCLUDED.metadata;
|
|
||||||
|
|
||||||
INSERT INTO public.institutes (id, name, urn, status, address, website, metadata, geo_coordinates)
|
|
||||||
VALUES (
|
|
||||||
'00000000-0000-4000-8000-000000000201',
|
|
||||||
'Classroom Copilot Dev School',
|
|
||||||
'DEV0001',
|
|
||||||
'active',
|
|
||||||
'{"line1":"1 Fixture Road","town":"Dev Town","postcode":"CC1 1DV","country":"GB"}',
|
|
||||||
'https://classroomcopilot.dev',
|
|
||||||
'{"seed":"dev","local_authority":"Fixture LA"}',
|
|
||||||
'{"lat":51.5007,"lon":-0.1246}'
|
|
||||||
)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, metadata = EXCLUDED.metadata;
|
|
||||||
|
|
||||||
UPDATE public.profiles
|
|
||||||
SET school_id = '00000000-0000-4000-8000-000000000201'
|
|
||||||
WHERE id <> '00000000-0000-4000-8000-000000000001';
|
|
||||||
|
|
||||||
INSERT INTO public.institute_memberships (profile_id, institute_id, role, metadata)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-4000-8000-000000000002', '00000000-0000-4000-8000-000000000201', 'school_admin', '{"seed":"dev"}'),
|
|
||||||
('00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', 'teacher', '{"seed":"dev"}'),
|
|
||||||
('00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', 'teacher', '{"seed":"dev"}'),
|
|
||||||
('00000000-0000-4000-8000-000000000101', '00000000-0000-4000-8000-000000000201', 'student', '{"seed":"dev"}'),
|
|
||||||
('00000000-0000-4000-8000-000000000102', '00000000-0000-4000-8000-000000000201', 'student', '{"seed":"dev"}'),
|
|
||||||
('00000000-0000-4000-8000-000000000103', '00000000-0000-4000-8000-000000000201', 'student', '{"seed":"dev"}')
|
|
||||||
ON CONFLICT (profile_id, institute_id) DO UPDATE SET role = EXCLUDED.role, metadata = EXCLUDED.metadata;
|
|
||||||
|
|
||||||
INSERT INTO public.classes (id, institute_id, name, class_code, subject, key_stage, year_group, academic_year, description, created_by)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000201', '9 Science A', '9SCI-A', 'Science', '3', '9', '2026-2027', 'Deterministic dev Year 9 science class', '00000000-0000-4000-8000-000000000002'),
|
|
||||||
('00000000-0000-4000-8000-000000000302', '00000000-0000-4000-8000-000000000201', '10 Physics B', '10PHY-B', 'Physics', '4', '10', '2026-2027', 'Deterministic dev Year 10 physics class', '00000000-0000-4000-8000-000000000002')
|
|
||||||
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, class_code = EXCLUDED.class_code;
|
|
||||||
|
|
||||||
INSERT INTO public.class_teachers (class_id, teacher_id, is_primary, assigned_by)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000011', true, '00000000-0000-4000-8000-000000000002'),
|
|
||||||
('00000000-0000-4000-8000-000000000302', '00000000-0000-4000-8000-000000000012', true, '00000000-0000-4000-8000-000000000002')
|
|
||||||
ON CONFLICT (class_id, teacher_id) DO UPDATE SET is_primary = EXCLUDED.is_primary;
|
|
||||||
|
|
||||||
INSERT INTO public.class_students (class_id, student_id, enrolled_by)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000101', '00000000-0000-4000-8000-000000000002'),
|
|
||||||
('00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000102', '00000000-0000-4000-8000-000000000002'),
|
|
||||||
('00000000-0000-4000-8000-000000000302', '00000000-0000-4000-8000-000000000103', '00000000-0000-4000-8000-000000000002')
|
|
||||||
ON CONFLICT (class_id, student_id) DO UPDATE SET status = 'active';
|
|
||||||
|
|
||||||
INSERT INTO public.whiteboard_rooms (id, user_id, institute_id, name, context_type, context_id, is_default, storage_path, node_type)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-4000-8000-000000000401', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', 'Ada Teacher Workspace', 'profile', '00000000-0000-4000-8000-000000000011', true, 'cc.users/00000000-0000-4000-8000-000000000011/tldraw/default.json', 'profile_workspace'),
|
|
||||||
('00000000-0000-4000-8000-000000000402', '00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', 'Alan Teacher Workspace', 'profile', '00000000-0000-4000-8000-000000000012', true, 'cc.users/00000000-0000-4000-8000-000000000012/tldraw/default.json', 'profile_workspace')
|
|
||||||
ON CONFLICT (id) DO UPDATE SET storage_path = EXCLUDED.storage_path, is_default = EXCLUDED.is_default;
|
|
||||||
|
|
||||||
INSERT INTO public.school_timetables (id, institute_id, year_label, start_date, end_date, periods_template)
|
|
||||||
VALUES (
|
|
||||||
'00000000-0000-4000-8000-000000000501',
|
|
||||||
'00000000-0000-4000-8000-000000000201',
|
|
||||||
'2026-2027',
|
|
||||||
'2026-09-01',
|
|
||||||
'2026-09-07',
|
|
||||||
'[{"code":"P1","name":"Period 1","type":"lesson","start_time":"09:00","end_time":"10:00"},{"code":"P2","name":"Period 2","type":"lesson","start_time":"10:05","end_time":"11:05"},{"code":"BR","name":"Break","type":"break","start_time":"11:05","end_time":"11:25"},{"code":"P3","name":"Period 3","type":"lesson","start_time":"11:25","end_time":"12:25"}]'
|
|
||||||
)
|
|
||||||
ON CONFLICT (institute_id, year_label) DO UPDATE SET periods_template = EXCLUDED.periods_template;
|
|
||||||
|
|
||||||
INSERT INTO public.academic_years (id, school_timetable_id, institute_id, year_label)
|
|
||||||
VALUES ('00000000-0000-4000-8000-000000000511', '00000000-0000-4000-8000-000000000501', '00000000-0000-4000-8000-000000000201', '2026-2027')
|
|
||||||
ON CONFLICT (school_timetable_id, year_label) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO public.academic_terms (id, academic_year_id, institute_id, term_name, term_number, start_date, end_date, notes, tags)
|
|
||||||
VALUES ('00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000511', '00000000-0000-4000-8000-000000000201', 'Autumn fixture week', 1, '2026-09-01', '2026-09-07', 'Single deterministic week for dev smoke tests', '{dev}')
|
|
||||||
ON CONFLICT (academic_year_id, term_number) DO UPDATE SET start_date = EXCLUDED.start_date, end_date = EXCLUDED.end_date;
|
|
||||||
|
|
||||||
INSERT INTO public.academic_weeks (id, academic_term_id, institute_id, week_number, start_date, week_cycle, notes, tags)
|
|
||||||
VALUES ('00000000-0000-4000-8000-000000000531', '00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000201', 1, '2026-09-01', 'A', 'Deterministic week A', '{dev}')
|
|
||||||
ON CONFLICT (academic_term_id, week_number) DO UPDATE SET week_cycle = EXCLUDED.week_cycle;
|
|
||||||
|
|
||||||
INSERT INTO public.academic_days (id, academic_week_id, academic_term_id, institute_id, date, day_of_week, academic_day_number, notes, tags)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-4000-8000-000000000541', '00000000-0000-4000-8000-000000000531', '00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000201', '2026-09-01', 'Tuesday', 1, 'Fixture day 1', '{dev}'),
|
|
||||||
('00000000-0000-4000-8000-000000000542', '00000000-0000-4000-8000-000000000531', '00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000201', '2026-09-02', 'Wednesday', 2, 'Fixture day 2', '{dev}'),
|
|
||||||
('00000000-0000-4000-8000-000000000543', '00000000-0000-4000-8000-000000000531', '00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000201', '2026-09-03', 'Thursday', 3, 'Fixture day 3', '{dev}'),
|
|
||||||
('00000000-0000-4000-8000-000000000544', '00000000-0000-4000-8000-000000000531', '00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000201', '2026-09-04', 'Friday', 4, 'Fixture day 4', '{dev}')
|
|
||||||
ON CONFLICT (institute_id, date) DO UPDATE SET academic_day_number = EXCLUDED.academic_day_number;
|
|
||||||
|
|
||||||
INSERT INTO public.academic_periods (id, academic_day_id, institute_id, period_code, period_name, period_type, start_time, end_time)
|
|
||||||
SELECT
|
|
||||||
('00000000-0000-4000-8000-' || lpad((600 + d.day_no * 10 + p.period_no)::text, 12, '0'))::uuid,
|
|
||||||
d.id,
|
|
||||||
'00000000-0000-4000-8000-000000000201'::uuid,
|
|
||||||
p.period_code,
|
|
||||||
p.period_name,
|
|
||||||
p.period_type,
|
|
||||||
p.start_time::time,
|
|
||||||
p.end_time::time
|
|
||||||
FROM (VALUES
|
|
||||||
('00000000-0000-4000-8000-000000000541'::uuid, 1),
|
|
||||||
('00000000-0000-4000-8000-000000000542'::uuid, 2),
|
|
||||||
('00000000-0000-4000-8000-000000000543'::uuid, 3),
|
|
||||||
('00000000-0000-4000-8000-000000000544'::uuid, 4)
|
|
||||||
) AS d(id, day_no)
|
|
||||||
CROSS JOIN (VALUES
|
|
||||||
(1, 'P1', 'Period 1', 'lesson', '09:00', '10:00'),
|
|
||||||
(2, 'P2', 'Period 2', 'lesson', '10:05', '11:05'),
|
|
||||||
(3, 'BR', 'Break', 'break', '11:05', '11:25'),
|
|
||||||
(4, 'P3', 'Period 3', 'lesson', '11:25', '12:25')
|
|
||||||
) AS p(period_no, period_code, period_name, period_type, start_time, end_time)
|
|
||||||
ON CONFLICT (academic_day_id, period_code) DO UPDATE SET period_name = EXCLUDED.period_name;
|
|
||||||
|
|
||||||
INSERT INTO public.teacher_timetables (id, profile_id, institute_id, school_timetable_id, start_date, end_date)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-4000-8000-000000000701', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000501', '2026-09-01', '2026-09-07'),
|
|
||||||
('00000000-0000-4000-8000-000000000702', '00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000501', '2026-09-01', '2026-09-07')
|
|
||||||
ON CONFLICT (profile_id, school_timetable_id) DO UPDATE SET start_date = EXCLUDED.start_date, end_date = EXCLUDED.end_date;
|
|
||||||
|
|
||||||
INSERT INTO public.teacher_timetable_slots (id, teacher_timetable_id, profile_id, institute_id, day_of_week, period_code, subject_class, start_time, end_time, week_cycle, class_id)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-4000-8000-000000000711', '00000000-0000-4000-8000-000000000701', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', 'Tuesday', 'P1', '9 Science A', '09:00', '10:00', 'A', '00000000-0000-4000-8000-000000000301'),
|
|
||||||
('00000000-0000-4000-8000-000000000712', '00000000-0000-4000-8000-000000000701', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', 'Wednesday', 'P2', '9 Science A', '10:05', '11:05', 'A', '00000000-0000-4000-8000-000000000301'),
|
|
||||||
('00000000-0000-4000-8000-000000000713', '00000000-0000-4000-8000-000000000702', '00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', 'Thursday', 'P1', '10 Physics B', '09:00', '10:00', 'A', '00000000-0000-4000-8000-000000000302')
|
|
||||||
ON CONFLICT (teacher_timetable_id, week_cycle, day_of_week, period_code) DO UPDATE SET class_id = EXCLUDED.class_id, subject_class = EXCLUDED.subject_class;
|
|
||||||
|
|
||||||
INSERT INTO public.taught_lessons (id, academic_period_id, teacher_timetable_slot_id, class_id, teacher_id, institute_id, date, period_code, week_cycle, day_of_week, lesson_plan, whiteboard_room_id, status, notes, tags)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-4000-8000-000000000801', '00000000-0000-4000-8000-000000000611', '00000000-0000-4000-8000-000000000711', '00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', '2026-09-01', 'P1', 'A', 'Tuesday', '{"title":"Forces baseline","objectives":["Describe balanced and unbalanced forces"]}', '00000000-0000-4000-8000-000000000401', 'planned', 'Dev fixture taught lesson', '{dev,science}'),
|
|
||||||
('00000000-0000-4000-8000-000000000802', '00000000-0000-4000-8000-000000000622', '00000000-0000-4000-8000-000000000712', '00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', '2026-09-02', 'P2', 'A', 'Wednesday', '{"title":"Particle model recap","objectives":["Compare solids liquids and gases"]}', '00000000-0000-4000-8000-000000000401', 'planned', 'Dev fixture taught lesson', '{dev,science}'),
|
|
||||||
('00000000-0000-4000-8000-000000000803', '00000000-0000-4000-8000-000000000631', '00000000-0000-4000-8000-000000000713', '00000000-0000-4000-8000-000000000302', '00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', '2026-09-03', 'P1', 'A', 'Thursday', '{"title":"Energy stores","objectives":["Identify energy stores in examples"]}', '00000000-0000-4000-8000-000000000402', 'planned', 'Dev fixture taught lesson', '{dev,physics}')
|
|
||||||
ON CONFLICT (academic_period_id, teacher_id) DO UPDATE SET lesson_plan = EXCLUDED.lesson_plan, status = EXCLUDED.status;
|
|
||||||
|
|
||||||
INSERT INTO public.curriculum_topics (id, title, subject, key_stage, year_group, topic_type, total_lessons, department)
|
|
||||||
VALUES
|
|
||||||
('DEV-SCI-FORCES', 'Forces baseline', 'Science', '3', '9', 'Standard', 1, 'Science'),
|
|
||||||
('DEV-PHY-ENERGY', 'Energy stores', 'Physics', '4', '10', 'Standard', 1, 'Science')
|
|
||||||
ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title;
|
|
||||||
|
|
||||||
INSERT INTO public.planned_lessons (id, created_by, institute_id, class_id, whiteboard_room_id, topic_code, title, subject, year_group, estimated_duration_minutes, objectives, activities, status, tags)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-4000-8000-000000000901', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000401', 'DEV-SCI-FORCES', 'Forces baseline planned lesson', 'Science', '9', 60, '["Describe balanced and unbalanced forces"]', '[{"type":"starter","title":"Force diagrams"}]', 'ready', '{dev,science}'),
|
|
||||||
('00000000-0000-4000-8000-000000000902', '00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000302', '00000000-0000-4000-8000-000000000402', 'DEV-PHY-ENERGY', 'Energy stores planned lesson', 'Physics', '10', 60, '["Identify energy stores"]', '[{"type":"main","title":"Energy transfer circus"}]', 'ready', '{dev,physics}')
|
|
||||||
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, objectives = EXCLUDED.objectives;
|
|
||||||
|
|
||||||
INSERT INTO public.lesson_deliveries (id, planned_lesson_id, taught_lesson_id, delivered_by, class_id, institute_id, whiteboard_room_id, started_at, ended_at, notes)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-4000-8000-000000000911', '00000000-0000-4000-8000-000000000901', '00000000-0000-4000-8000-000000000801', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000401', '2026-09-01 09:00:00+00', '2026-09-01 10:00:00+00', 'Delivered lesson fixture')
|
|
||||||
ON CONFLICT (id) DO UPDATE SET taught_lesson_id = EXCLUDED.taught_lesson_id;
|
|
||||||
|
|
||||||
-- Dev storage buckets expected by app/API. Object rows are intentionally omitted;
|
|
||||||
-- TLDraw paths above point at where empty/default snapshots should be written.
|
|
||||||
INSERT INTO storage.buckets (id, name, public)
|
|
||||||
VALUES
|
|
||||||
('cc.users', 'cc.users', false),
|
|
||||||
('cc.public.snapshots', 'cc.public.snapshots', false),
|
|
||||||
('cc.examboards', 'cc.examboards', true)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET public = EXCLUDED.public;
|
|
||||||
|
|
||||||
-- Lightweight verification breadcrumbs for SQL-level smoke checks.
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
profile_count integer;
|
|
||||||
institute_count integer;
|
|
||||||
class_count integer;
|
|
||||||
taught_count integer;
|
|
||||||
BEGIN
|
|
||||||
SELECT count(*) INTO profile_count FROM public.profiles WHERE metadata->>'seed' = 'dev';
|
|
||||||
SELECT count(*) INTO institute_count FROM public.institutes WHERE metadata->>'seed' = 'dev';
|
|
||||||
SELECT count(*) INTO class_count FROM public.classes WHERE academic_year = '2026-2027';
|
|
||||||
SELECT count(*) INTO taught_count FROM public.taught_lessons WHERE tags @> ARRAY['dev'];
|
|
||||||
|
|
||||||
IF profile_count <> 7 OR institute_count <> 1 OR class_count <> 2 OR taught_count <> 3 THEN
|
|
||||||
RAISE EXCEPTION 'Dev seed verification failed: profiles=%, institutes=%, classes=%, taught_lessons=%', profile_count, institute_count, class_count, taught_count;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
79
volumes/db/cc/67-vectors.sql
Normal file
79
volumes/db/cc/67-vectors.sql
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
-- Vectors: file_vectors table and similarity search function
|
||||||
|
|
||||||
|
-- 1) Ensure pgvector extension is available
|
||||||
|
create extension if not exists vector;
|
||||||
|
|
||||||
|
-- 2) File vectors table
|
||||||
|
create table if not exists public.file_vectors (
|
||||||
|
id bigint generated by default as identity primary key,
|
||||||
|
created_at timestamp with time zone default now() not null,
|
||||||
|
embedding public.vector,
|
||||||
|
metadata jsonb,
|
||||||
|
content text
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3) ANN index (skipped until embedding dimension is fixed)
|
||||||
|
-- To enable: set column type to public.vector(<dim>) and uncomment:
|
||||||
|
-- create index if not exists file_vectors_embedding_idx
|
||||||
|
-- on public.file_vectors using ivfflat (embedding public.vector_cosine_ops)
|
||||||
|
-- with (lists='100');
|
||||||
|
|
||||||
|
-- 3b) Enable RLS and set policies (moved here to avoid ordering issues)
|
||||||
|
alter table if exists public.file_vectors enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists "vectors_read_by_owner" on public.file_vectors;
|
||||||
|
create policy "vectors_read_by_owner" on public.file_vectors for select to authenticated
|
||||||
|
using (coalesce((metadata->>'file_id')::uuid, null) is null or exists (
|
||||||
|
select 1 from public.files f join public.file_cabinets c on f.cabinet_id = c.id
|
||||||
|
where f.id = (metadata->>'file_id')::uuid and c.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
drop policy if exists "vectors_rw_service" on public.file_vectors;
|
||||||
|
create policy "vectors_rw_service" on public.file_vectors to service_role
|
||||||
|
using (true) with check (true);
|
||||||
|
|
||||||
|
-- 4) Match function mirrored from neoFS (generic metadata mapping)
|
||||||
|
create or replace function public.match_file_vectors(
|
||||||
|
filter jsonb,
|
||||||
|
match_count integer,
|
||||||
|
query_embedding public.vector
|
||||||
|
)
|
||||||
|
returns table (
|
||||||
|
id bigint,
|
||||||
|
file_id uuid,
|
||||||
|
cabinet_id uuid,
|
||||||
|
artefact_type text,
|
||||||
|
artefact_is text,
|
||||||
|
original_path_prefix text,
|
||||||
|
original_filename text,
|
||||||
|
content text,
|
||||||
|
metadata jsonb,
|
||||||
|
similarity double precision
|
||||||
|
)
|
||||||
|
language sql stable as $$
|
||||||
|
select
|
||||||
|
fv.id,
|
||||||
|
nullif(fv.metadata->>'file_id','')::uuid as file_id,
|
||||||
|
nullif(fv.metadata->>'cabinet_id','')::uuid as cabinet_id,
|
||||||
|
nullif(fv.metadata->>'artefact_type','') as artefact_type,
|
||||||
|
nullif(fv.metadata->>'artefact_is','') as artefact_is,
|
||||||
|
nullif(fv.metadata->>'original_path_prefix','') as original_path_prefix,
|
||||||
|
nullif(fv.metadata->>'original_filename','') as original_filename,
|
||||||
|
fv.content,
|
||||||
|
fv.metadata,
|
||||||
|
1 - (fv.embedding <=> query_embedding) as similarity
|
||||||
|
from public.file_vectors fv
|
||||||
|
where
|
||||||
|
(coalesce(filter ? 'file_id', false) = false or (fv.metadata->>'file_id')::uuid = (filter->>'file_id')::uuid)
|
||||||
|
and (coalesce(filter ? 'cabinet_id', false) = false or (fv.metadata->>'cabinet_id')::uuid = (filter->>'cabinet_id')::uuid)
|
||||||
|
and (coalesce(filter ? 'artefact_type', false) = false or (fv.metadata->>'artefact_type') = (filter->>'artefact_type'))
|
||||||
|
and (coalesce(filter ? 'artefact_id', false) = false or (fv.metadata->>'artefact_id') = (filter->>'artefact_id'))
|
||||||
|
and (coalesce(filter ? 'original_path_prefix', false) = false or (fv.metadata->>'original_path_prefix') like (filter->>'original_path_prefix') || '%')
|
||||||
|
and (coalesce(filter ? 'original_path_prefix_ilike', false)= false or (fv.metadata->>'original_path_prefix') ilike (filter->>'original_path_prefix_ilike') || '%')
|
||||||
|
and (coalesce(filter ? 'original_filename', false) = false or (fv.metadata->>'original_filename') = (filter->>'original_filename'))
|
||||||
|
and (coalesce(filter ? 'original_filename_ilike', false)= false or (fv.metadata->>'original_filename') ilike (filter->>'original_filename_ilike'))
|
||||||
|
order by fv.embedding <=> query_embedding
|
||||||
|
limit greatest(coalesce(match_count, 10), 1)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
73
volumes/db/cc/68-cabinet-memberships.sql
Normal file
73
volumes/db/cc/68-cabinet-memberships.sql
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
-- Cabinet memberships for sharing access
|
||||||
|
|
||||||
|
create table if not exists public.cabinet_memberships (
|
||||||
|
id uuid default uuid_generate_v4() primary key,
|
||||||
|
cabinet_id uuid not null references public.file_cabinets(id) on delete cascade,
|
||||||
|
profile_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
role text not null check (role in ('owner','editor','viewer')),
|
||||||
|
created_at timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
updated_at timestamp with time zone default timezone('utc'::text, now()),
|
||||||
|
unique(cabinet_id, profile_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_cabinet_memberships_cabinet on public.cabinet_memberships(cabinet_id);
|
||||||
|
create index if not exists idx_cabinet_memberships_profile on public.cabinet_memberships(profile_id);
|
||||||
|
|
||||||
|
-- Updated at trigger
|
||||||
|
drop trigger if exists trg_cabinet_memberships_updated_at on public.cabinet_memberships;
|
||||||
|
create trigger trg_cabinet_memberships_updated_at
|
||||||
|
before update on public.cabinet_memberships
|
||||||
|
for each row execute function public.handle_updated_at();
|
||||||
|
|
||||||
|
-- RLS and policies
|
||||||
|
alter table if exists public.cabinet_memberships enable row level security;
|
||||||
|
|
||||||
|
-- Members can select their own memberships; cabinet owners can also see memberships
|
||||||
|
drop policy if exists cm_read_self_or_owner on public.cabinet_memberships;
|
||||||
|
create policy cm_read_self_or_owner on public.cabinet_memberships for select to authenticated
|
||||||
|
using (
|
||||||
|
profile_id = auth.uid() or exists (
|
||||||
|
select 1 from public.file_cabinets c where c.id = cabinet_memberships.cabinet_id and c.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Cabinet owners can insert memberships
|
||||||
|
drop policy if exists cm_insert_by_owner on public.cabinet_memberships;
|
||||||
|
create policy cm_insert_by_owner on public.cabinet_memberships for insert to authenticated
|
||||||
|
with check (exists (
|
||||||
|
select 1 from public.file_cabinets c where c.id = cabinet_memberships.cabinet_id and c.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
-- Cabinet owners can update memberships (e.g., role)
|
||||||
|
drop policy if exists cm_update_by_owner on public.cabinet_memberships;
|
||||||
|
create policy cm_update_by_owner on public.cabinet_memberships for update to authenticated
|
||||||
|
using (exists (
|
||||||
|
select 1 from public.file_cabinets c where c.id = cabinet_memberships.cabinet_id and c.user_id = auth.uid()
|
||||||
|
))
|
||||||
|
with check (exists (
|
||||||
|
select 1 from public.file_cabinets c where c.id = cabinet_memberships.cabinet_id and c.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
-- Cabinet owners can delete memberships
|
||||||
|
drop policy if exists cm_delete_by_owner on public.cabinet_memberships;
|
||||||
|
create policy cm_delete_by_owner on public.cabinet_memberships for delete to authenticated
|
||||||
|
using (exists (
|
||||||
|
select 1 from public.file_cabinets c where c.id = cabinet_memberships.cabinet_id and c.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
-- Extend access to cabinets/files for members (after table exists)
|
||||||
|
drop policy if exists "User can access cabinets via membership" on public.file_cabinets;
|
||||||
|
create policy "User can access cabinets via membership" on public.file_cabinets for select to authenticated
|
||||||
|
using (exists (
|
||||||
|
select 1 from public.cabinet_memberships m
|
||||||
|
where m.cabinet_id = file_cabinets.id and m.profile_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
drop policy if exists "User can access files via membership" on public.files;
|
||||||
|
create policy "User can access files via membership" on public.files for select to authenticated
|
||||||
|
using (exists (
|
||||||
|
select 1 from public.cabinet_memberships m
|
||||||
|
where m.cabinet_id = files.cabinet_id and m.profile_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
|
||||||
48
volumes/db/cc/69-gc-prefix-cleanup.sql
Normal file
48
volumes/db/cc/69-gc-prefix-cleanup.sql
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
-- Ensure storage objects for all artefacts are removed when a file is deleted
|
||||||
|
-- by deleting the entire "cabinet_id/file_id" directory prefix in Storage.
|
||||||
|
|
||||||
|
-- Helper to delete all objects under a prefix
|
||||||
|
create or replace function public._delete_storage_prefix(p_bucket text, p_prefix text)
|
||||||
|
returns void
|
||||||
|
language plpgsql security definer
|
||||||
|
set search_path to 'public', 'storage'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if p_bucket is null or p_prefix is null then
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
-- Delete any objects whose name starts with the prefix + '/'
|
||||||
|
delete from storage.objects where bucket_id = p_bucket and name like p_prefix || '/%';
|
||||||
|
-- In case an object exists exactly at the prefix (rare but safe)
|
||||||
|
delete from storage.objects where bucket_id = p_bucket and name = p_prefix;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Update file-level GC to also delete the parent directory prefix (cabinet_id/file_id)
|
||||||
|
create or replace function public._storage_gc_sql()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql security definer
|
||||||
|
set search_path to 'public', 'storage'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_prefix text;
|
||||||
|
begin
|
||||||
|
-- Derive directory prefix from the file path by removing the last segment (filename)
|
||||||
|
-- Example: 'cabinet_id/file_id/filename.ext' -> 'cabinet_id/file_id'
|
||||||
|
v_prefix := regexp_replace(old.path, '/[^/]+$', '');
|
||||||
|
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
-- Delete the original object and any artefacts under the file's directory
|
||||||
|
perform public._delete_storage_objects(old.bucket, old.path);
|
||||||
|
perform public._delete_storage_prefix(old.bucket, v_prefix);
|
||||||
|
elsif tg_op = 'UPDATE' then
|
||||||
|
if (old.bucket is distinct from new.bucket) or (old.path is distinct from new.path) then
|
||||||
|
perform public._delete_storage_objects(old.bucket, old.path);
|
||||||
|
perform public._delete_storage_prefix(old.bucket, v_prefix);
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
41
volumes/db/cc/70-add-directory-support.sql
Normal file
41
volumes/db/cc/70-add-directory-support.sql
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
-- Add directory support to files table
|
||||||
|
-- Migration: Add directory support for folder uploads
|
||||||
|
|
||||||
|
-- Add new columns to files table
|
||||||
|
ALTER TABLE files
|
||||||
|
ADD COLUMN IF NOT EXISTS is_directory BOOLEAN DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS parent_directory_id UUID REFERENCES files(id) ON DELETE CASCADE,
|
||||||
|
ADD COLUMN IF NOT EXISTS relative_path TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS directory_manifest JSONB,
|
||||||
|
ADD COLUMN IF NOT EXISTS upload_session_id UUID,
|
||||||
|
ADD COLUMN IF NOT EXISTS processing_status TEXT DEFAULT 'uploaded' CHECK (processing_status IN ('uploaded', 'processing', 'completed', 'failed', 'queued'));
|
||||||
|
|
||||||
|
-- Create index for efficient directory queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_parent_directory ON files(parent_directory_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_upload_session ON files(upload_session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_processing_status ON files(processing_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_is_directory ON files(is_directory);
|
||||||
|
|
||||||
|
-- Create directory manifest structure
|
||||||
|
COMMENT ON COLUMN files.is_directory IS 'True if this record represents a directory/folder';
|
||||||
|
COMMENT ON COLUMN files.parent_directory_id IS 'ID of parent directory if this file is inside an uploaded folder';
|
||||||
|
COMMENT ON COLUMN files.relative_path IS 'Relative path within the uploaded directory structure';
|
||||||
|
COMMENT ON COLUMN files.directory_manifest IS 'JSON manifest of directory contents including file count, total size, structure';
|
||||||
|
COMMENT ON COLUMN files.upload_session_id IS 'Groups files uploaded together in a single directory upload session';
|
||||||
|
COMMENT ON COLUMN files.processing_status IS 'Simple status tracking without auto-processing';
|
||||||
|
|
||||||
|
-- Example directory_manifest structure:
|
||||||
|
-- {
|
||||||
|
-- "total_files": 15,
|
||||||
|
-- "total_size_bytes": 12345678,
|
||||||
|
-- "directory_structure": {
|
||||||
|
-- "documents/": {
|
||||||
|
-- "file1.pdf": {"size": 123456, "mime_type": "application/pdf"},
|
||||||
|
-- "subdirectory/": {
|
||||||
|
-- "file2.docx": {"size": 234567, "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}
|
||||||
|
-- }
|
||||||
|
-- }
|
||||||
|
-- },
|
||||||
|
-- "upload_timestamp": "2024-09-23T12:00:00Z",
|
||||||
|
-- "upload_method": "directory_picker"
|
||||||
|
-- }
|
||||||
184
volumes/db/cc/71-class-management.sql
Normal file
184
volumes/db/cc/71-class-management.sql
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
-- 71-class-management.sql
|
||||||
|
-- Foundational: capture the (previously untracked) class-management schema and harden its RLS.
|
||||||
|
--
|
||||||
|
-- Background: `classes`, `class_teachers`, `class_students`, `enrollment_requests` existed only on
|
||||||
|
-- live dev (.94), created out-of-band, with NO tracked DDL. Their schema/FKs/uniques are sound,
|
||||||
|
-- but RLS exposed `class_students` / `class_teachers` to service_role ONLY — so any API path that
|
||||||
|
-- calls Supabase AS THE USER (the correct, RLS-enforced pattern) reads ZERO rows. This migration:
|
||||||
|
-- 1. captures the real schema (idempotent; no-op on environments that already have it),
|
||||||
|
-- 2. adds SECURITY DEFINER membership helpers (avoid RLS recursion in policies),
|
||||||
|
-- 3. adds as-user RLS policies so teachers/admins/students can read rosters under RLS,
|
||||||
|
-- while keeping the existing service_role policies intact.
|
||||||
|
-- Verified against live .94 schema 2026-06-06 (all FKs/uniques/checks already present there).
|
||||||
|
|
||||||
|
--==========================================================================================
|
||||||
|
-- 1. Tables (idempotent capture for fresh environments; skipped where they already exist)
|
||||||
|
--==========================================================================================
|
||||||
|
|
||||||
|
create table if not exists public.classes (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
institute_id uuid not null references public.institutes(id) on delete cascade,
|
||||||
|
name varchar not null,
|
||||||
|
class_code text,
|
||||||
|
subject varchar,
|
||||||
|
key_stage text,
|
||||||
|
year_group varchar,
|
||||||
|
academic_year varchar,
|
||||||
|
description text,
|
||||||
|
type varchar not null default 'standard',
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_by uuid not null references public.profiles(id),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.class_teachers (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
class_id uuid not null references public.classes(id) on delete cascade,
|
||||||
|
teacher_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
is_primary boolean not null default false,
|
||||||
|
can_edit boolean not null default true,
|
||||||
|
assigned_at timestamptz not null default now(),
|
||||||
|
assigned_by uuid references public.profiles(id),
|
||||||
|
constraint class_teachers_class_id_teacher_id_key unique (class_id, teacher_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.class_students (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
class_id uuid not null references public.classes(id) on delete cascade,
|
||||||
|
student_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
status varchar not null default 'active'
|
||||||
|
check (status::text = any (array['active','inactive','pending'])),
|
||||||
|
enrolled_at timestamptz not null default now(),
|
||||||
|
enrolled_by uuid references public.profiles(id),
|
||||||
|
constraint class_students_class_id_student_id_key unique (class_id, student_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.enrollment_requests (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
class_id uuid not null references public.classes(id) on delete cascade,
|
||||||
|
student_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
status varchar not null default 'pending'
|
||||||
|
check (status::text = any (array['pending','approved','rejected'])),
|
||||||
|
request_message text,
|
||||||
|
requested_at timestamptz not null default now(),
|
||||||
|
responded_at timestamptz,
|
||||||
|
responded_by uuid references public.profiles(id),
|
||||||
|
response_message text
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_classes_institute on public.classes(institute_id);
|
||||||
|
create index if not exists idx_classes_created_by on public.classes(created_by);
|
||||||
|
create index if not exists idx_classes_class_code on public.classes(class_code);
|
||||||
|
create index if not exists idx_class_teachers_class on public.class_teachers(class_id);
|
||||||
|
create index if not exists idx_class_students_class on public.class_students(class_id);
|
||||||
|
|
||||||
|
--==========================================================================================
|
||||||
|
-- 2. SECURITY DEFINER membership helpers
|
||||||
|
-- Run as owner (bypass RLS on the inner tables) → no policy recursion when referenced below.
|
||||||
|
--==========================================================================================
|
||||||
|
|
||||||
|
create or replace function public.user_institute_ids()
|
||||||
|
returns setof uuid language sql stable security definer set search_path = public as $$
|
||||||
|
select institute_id from public.institute_memberships where profile_id = auth.uid()
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.is_class_teacher(p_class uuid)
|
||||||
|
returns boolean language sql stable security definer set search_path = public as $$
|
||||||
|
select exists (select 1 from public.class_teachers
|
||||||
|
where class_id = p_class and teacher_id = auth.uid())
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.is_class_admin(p_class uuid)
|
||||||
|
returns boolean language sql stable security definer set search_path = public as $$
|
||||||
|
select exists (
|
||||||
|
select 1 from public.classes c
|
||||||
|
join public.institute_memberships m on m.institute_id = c.institute_id
|
||||||
|
where c.id = p_class and m.profile_id = auth.uid()
|
||||||
|
and m.role in ('school_admin','department_head'))
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.is_institute_member_of_class(p_class uuid)
|
||||||
|
returns boolean language sql stable security definer set search_path = public as $$
|
||||||
|
select exists (
|
||||||
|
select 1 from public.classes c
|
||||||
|
join public.institute_memberships m on m.institute_id = c.institute_id
|
||||||
|
where c.id = p_class and m.profile_id = auth.uid())
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--==========================================================================================
|
||||||
|
-- 3. RLS — enable + (re)declare every policy so this file is the source of truth.
|
||||||
|
-- Existing service_role / institute_read / er_own policies are preserved verbatim;
|
||||||
|
-- the cs_read/cs_write/ct_read/ct_write policies are the NEW as-user grants.
|
||||||
|
--==========================================================================================
|
||||||
|
|
||||||
|
alter table public.classes enable row level security;
|
||||||
|
alter table public.class_teachers enable row level security;
|
||||||
|
alter table public.class_students enable row level security;
|
||||||
|
alter table public.enrollment_requests enable row level security;
|
||||||
|
|
||||||
|
-- classes ---------------------------------------------------------------------------------
|
||||||
|
drop policy if exists classes_service_role on public.classes;
|
||||||
|
create policy classes_service_role on public.classes
|
||||||
|
using (auth.role() = 'service_role');
|
||||||
|
|
||||||
|
drop policy if exists classes_institute_read on public.classes;
|
||||||
|
create policy classes_institute_read on public.classes for select to authenticated
|
||||||
|
using (institute_id in (select public.user_institute_ids()));
|
||||||
|
|
||||||
|
-- NEW: teachers/admins of a class can update it; admins can insert/delete within their institute
|
||||||
|
drop policy if exists classes_admin_write on public.classes;
|
||||||
|
create policy classes_admin_write on public.classes for all to authenticated
|
||||||
|
using (institute_id in (select public.user_institute_ids())
|
||||||
|
and (public.is_class_admin(id) or public.is_class_teacher(id)))
|
||||||
|
with check (institute_id in (select public.user_institute_ids()));
|
||||||
|
|
||||||
|
-- class_teachers --------------------------------------------------------------------------
|
||||||
|
drop policy if exists ct_service_role on public.class_teachers;
|
||||||
|
create policy ct_service_role on public.class_teachers
|
||||||
|
using (auth.role() = 'service_role');
|
||||||
|
|
||||||
|
-- NEW: institute members can see who teaches a class; the teacher can see their own rows
|
||||||
|
drop policy if exists ct_read on public.class_teachers;
|
||||||
|
create policy ct_read on public.class_teachers for select to authenticated
|
||||||
|
using (teacher_id = auth.uid() or public.is_institute_member_of_class(class_id));
|
||||||
|
|
||||||
|
-- NEW: only school admins assign/unassign teachers
|
||||||
|
drop policy if exists ct_write on public.class_teachers;
|
||||||
|
create policy ct_write on public.class_teachers for all to authenticated
|
||||||
|
using (public.is_class_admin(class_id))
|
||||||
|
with check (public.is_class_admin(class_id));
|
||||||
|
|
||||||
|
-- class_students --------------------------------------------------------------------------
|
||||||
|
drop policy if exists cs_service_role on public.class_students;
|
||||||
|
create policy cs_service_role on public.class_students
|
||||||
|
using (auth.role() = 'service_role');
|
||||||
|
|
||||||
|
-- NEW: a student sees their own enrolment; teachers/admins of the class see the roster
|
||||||
|
drop policy if exists cs_read on public.class_students;
|
||||||
|
create policy cs_read on public.class_students for select to authenticated
|
||||||
|
using (student_id = auth.uid()
|
||||||
|
or public.is_class_teacher(class_id)
|
||||||
|
or public.is_class_admin(class_id));
|
||||||
|
|
||||||
|
-- NEW: teachers (can_edit) and admins of the class manage enrolments
|
||||||
|
drop policy if exists cs_write on public.class_students;
|
||||||
|
create policy cs_write on public.class_students for all to authenticated
|
||||||
|
using (public.is_class_teacher(class_id) or public.is_class_admin(class_id))
|
||||||
|
with check (public.is_class_teacher(class_id) or public.is_class_admin(class_id));
|
||||||
|
|
||||||
|
-- enrollment_requests ---------------------------------------------------------------------
|
||||||
|
drop policy if exists er_service_role on public.enrollment_requests;
|
||||||
|
create policy er_service_role on public.enrollment_requests
|
||||||
|
using (auth.role() = 'service_role');
|
||||||
|
|
||||||
|
drop policy if exists er_own on public.enrollment_requests;
|
||||||
|
create policy er_own on public.enrollment_requests for all to authenticated
|
||||||
|
using (student_id = auth.uid())
|
||||||
|
with check (student_id = auth.uid());
|
||||||
|
|
||||||
|
-- NEW: teachers/admins of the class can read + respond to requests for their class
|
||||||
|
drop policy if exists er_class_staff on public.enrollment_requests;
|
||||||
|
create policy er_class_staff on public.enrollment_requests for all to authenticated
|
||||||
|
using (public.is_class_teacher(class_id) or public.is_class_admin(class_id))
|
||||||
|
with check (public.is_class_teacher(class_id) or public.is_class_admin(class_id));
|
||||||
238
volumes/db/cc/72-exam-marker.sql
Normal file
238
volumes/db/cc/72-exam-marker.sql
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
-- 72-exam-marker.sql
|
||||||
|
-- Exam-marker operational tables (Supabase = source of truth for geometry/marks/submissions).
|
||||||
|
-- Neo4j cc.public.exams holds the knowledge graph; joined by shared UUIDs (see spec §2).
|
||||||
|
--
|
||||||
|
-- Authorization is owned by this layer: the exam API calls Supabase AS THE USER, so these RLS
|
||||||
|
-- policies are enforced (service_role policies cover the Neo4j-projection / seed paths only).
|
||||||
|
-- Depends on: 71-class-management.sql (marking_batches.class_id → classes; user_institute_ids()).
|
||||||
|
|
||||||
|
--==========================================================================================
|
||||||
|
-- 1. Tables
|
||||||
|
--==========================================================================================
|
||||||
|
|
||||||
|
create table if not exists public.exam_templates (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
exam_id uuid references public.eb_exams(id) on delete set null, -- null for ad-hoc upload
|
||||||
|
exam_code text, -- denormalised → Neo4j join
|
||||||
|
institute_id uuid not null references public.institutes(id) on delete cascade,
|
||||||
|
teacher_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
title text not null,
|
||||||
|
subject text,
|
||||||
|
source_file_id uuid references public.files(id) on delete set null, -- uploaded PDF (R2.2)
|
||||||
|
page_count int,
|
||||||
|
status text not null default 'draft' check (status in ('draft','ready','archived')),
|
||||||
|
created_at timestamptz not null default timezone('utc', now()),
|
||||||
|
updated_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.exam_questions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
template_id uuid not null references public.exam_templates(id) on delete cascade,
|
||||||
|
parent_id uuid references public.exam_questions(id) on delete cascade,
|
||||||
|
label text not null,
|
||||||
|
"order" int not null default 0,
|
||||||
|
max_marks numeric not null default 0,
|
||||||
|
answer_type text check (answer_type in ('written','mcq','short','diagram')),
|
||||||
|
mcq_options jsonb,
|
||||||
|
mark_scheme jsonb not null default '{}'::jsonb, -- MarkScheme union from exam-marker types.ts
|
||||||
|
is_container boolean not null default false, -- true → Neo4j Question, false → Part
|
||||||
|
spec_ref text, -- manual spec-point tag → ASSESSES (R3.5.3)
|
||||||
|
created_at timestamptz not null default timezone('utc', now()),
|
||||||
|
updated_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.exam_response_areas (
|
||||||
|
id uuid primary key default gen_random_uuid(), -- == Neo4j Region.uuid_string
|
||||||
|
question_id uuid not null references public.exam_questions(id) on delete cascade,
|
||||||
|
template_id uuid not null references public.exam_templates(id) on delete cascade, -- RLS denorm
|
||||||
|
page int not null,
|
||||||
|
bounds jsonb not null, -- {x,y,w,h}
|
||||||
|
kind text not null check (kind in ('response','context')),
|
||||||
|
response_form text check (response_form in
|
||||||
|
('lines','answer-box','working','diagram','tick-boxes','table','blanks')),
|
||||||
|
source text not null default 'manual' check (source in ('manual','ai')),
|
||||||
|
confirmed boolean not null default true,
|
||||||
|
confidence numeric,
|
||||||
|
created_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.exam_boundaries (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
template_id uuid not null references public.exam_templates(id) on delete cascade,
|
||||||
|
question_id uuid references public.exam_questions(id) on delete set null,
|
||||||
|
label text,
|
||||||
|
page_index int not null,
|
||||||
|
y numeric not null,
|
||||||
|
bounds jsonb,
|
||||||
|
source text not null default 'manual' check (source in ('manual','ai')),
|
||||||
|
confirmed boolean not null default true,
|
||||||
|
created_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.marking_batches (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
template_id uuid not null references public.exam_templates(id) on delete cascade,
|
||||||
|
class_id uuid references public.classes(id) on delete set null, -- roster via class_students
|
||||||
|
institute_id uuid not null references public.institutes(id) on delete cascade,
|
||||||
|
teacher_id uuid not null references public.profiles(id) on delete cascade, -- batch owner (R2.4)
|
||||||
|
title text,
|
||||||
|
status text not null default 'open' check (status in ('open','marking','complete','archived')),
|
||||||
|
created_at timestamptz not null default timezone('utc', now()),
|
||||||
|
updated_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.student_submissions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
batch_id uuid not null references public.marking_batches(id) on delete cascade,
|
||||||
|
student_id uuid references public.profiles(id) on delete set null, -- null until matched
|
||||||
|
student_name text,
|
||||||
|
scan_file_id uuid references public.files(id) on delete set null,
|
||||||
|
scan_url text,
|
||||||
|
qr_code text,
|
||||||
|
matching_method text check (matching_method in ('ordered','ocr_name','qr_code','manual')),
|
||||||
|
match_confidence numeric,
|
||||||
|
page_start int,
|
||||||
|
page_count int,
|
||||||
|
status text not null default 'unmatched'
|
||||||
|
check (status in ('unmatched','matched','marking','complete','absent')),
|
||||||
|
annotation_snapshot jsonb,
|
||||||
|
created_at timestamptz not null default timezone('utc', now()),
|
||||||
|
updated_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.mark_entries (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
submission_id uuid not null references public.student_submissions(id) on delete cascade,
|
||||||
|
question_id uuid not null references public.exam_questions(id) on delete cascade,
|
||||||
|
batch_id uuid not null references public.marking_batches(id) on delete cascade, -- RLS denorm
|
||||||
|
awarded_marks numeric not null default 0,
|
||||||
|
mark_scheme_detail jsonb not null default '{}'::jsonb,
|
||||||
|
annotation_shape_ids jsonb not null default '[]'::jsonb,
|
||||||
|
comment text,
|
||||||
|
marked_by text not null default 'teacher' check (marked_by in ('teacher','ai')),
|
||||||
|
ai_confidence numeric,
|
||||||
|
confirmed boolean not null default true,
|
||||||
|
marked_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_exam_templates_institute on public.exam_templates(institute_id);
|
||||||
|
create index if not exists idx_exam_templates_teacher on public.exam_templates(teacher_id);
|
||||||
|
create index if not exists idx_exam_questions_template on public.exam_questions(template_id);
|
||||||
|
create index if not exists idx_exam_questions_parent on public.exam_questions(parent_id);
|
||||||
|
create index if not exists idx_exam_regions_question on public.exam_response_areas(question_id);
|
||||||
|
create index if not exists idx_exam_regions_template on public.exam_response_areas(template_id);
|
||||||
|
create index if not exists idx_exam_boundaries_template on public.exam_boundaries(template_id);
|
||||||
|
create index if not exists idx_batches_template on public.marking_batches(template_id);
|
||||||
|
create index if not exists idx_batches_institute on public.marking_batches(institute_id);
|
||||||
|
create index if not exists idx_submissions_batch on public.student_submissions(batch_id);
|
||||||
|
create index if not exists idx_marks_submission on public.mark_entries(submission_id);
|
||||||
|
create index if not exists idx_marks_batch on public.mark_entries(batch_id);
|
||||||
|
|
||||||
|
--==========================================================================================
|
||||||
|
-- 2. updated_at triggers (reuse public.handle_updated_at from 62-functions-triggers.sql)
|
||||||
|
--==========================================================================================
|
||||||
|
|
||||||
|
drop trigger if exists handle_exam_templates_updated_at on public.exam_templates;
|
||||||
|
create trigger handle_exam_templates_updated_at before update on public.exam_templates
|
||||||
|
for each row execute function public.handle_updated_at();
|
||||||
|
|
||||||
|
drop trigger if exists handle_exam_questions_updated_at on public.exam_questions;
|
||||||
|
create trigger handle_exam_questions_updated_at before update on public.exam_questions
|
||||||
|
for each row execute function public.handle_updated_at();
|
||||||
|
|
||||||
|
drop trigger if exists handle_marking_batches_updated_at on public.marking_batches;
|
||||||
|
create trigger handle_marking_batches_updated_at before update on public.marking_batches
|
||||||
|
for each row execute function public.handle_updated_at();
|
||||||
|
|
||||||
|
drop trigger if exists handle_student_submissions_updated_at on public.student_submissions;
|
||||||
|
create trigger handle_student_submissions_updated_at before update on public.student_submissions
|
||||||
|
for each row execute function public.handle_updated_at();
|
||||||
|
|
||||||
|
--==========================================================================================
|
||||||
|
-- 3. RLS — every table: a service_role passthrough (Neo4j projection / seeds) + as-user policies
|
||||||
|
--==========================================================================================
|
||||||
|
|
||||||
|
alter table public.exam_templates enable row level security;
|
||||||
|
alter table public.exam_questions enable row level security;
|
||||||
|
alter table public.exam_response_areas enable row level security;
|
||||||
|
alter table public.exam_boundaries enable row level security;
|
||||||
|
alter table public.marking_batches enable row level security;
|
||||||
|
alter table public.student_submissions enable row level security;
|
||||||
|
alter table public.mark_entries enable row level security;
|
||||||
|
|
||||||
|
-- exam_templates -------------------------------------------------------------------------
|
||||||
|
drop policy if exists exam_templates_service on public.exam_templates;
|
||||||
|
create policy exam_templates_service on public.exam_templates using (auth.role() = 'service_role');
|
||||||
|
drop policy if exists exam_templates_read on public.exam_templates;
|
||||||
|
create policy exam_templates_read on public.exam_templates for select to authenticated
|
||||||
|
using (institute_id in (select public.user_institute_ids()));
|
||||||
|
drop policy if exists exam_templates_write on public.exam_templates;
|
||||||
|
create policy exam_templates_write on public.exam_templates for all to authenticated
|
||||||
|
using (teacher_id = auth.uid() and institute_id in (select public.user_institute_ids()))
|
||||||
|
with check (teacher_id = auth.uid() and institute_id in (select public.user_institute_ids()));
|
||||||
|
|
||||||
|
-- exam_questions / exam_response_areas / exam_boundaries: cascade authz from owning template
|
||||||
|
drop policy if exists exam_questions_service on public.exam_questions;
|
||||||
|
create policy exam_questions_service on public.exam_questions using (auth.role() = 'service_role');
|
||||||
|
drop policy if exists exam_questions_all on public.exam_questions;
|
||||||
|
create policy exam_questions_all on public.exam_questions for all to authenticated
|
||||||
|
using (exists (select 1 from public.exam_templates t
|
||||||
|
where t.id = exam_questions.template_id
|
||||||
|
and t.institute_id in (select public.user_institute_ids())))
|
||||||
|
with check (exists (select 1 from public.exam_templates t
|
||||||
|
where t.id = exam_questions.template_id and t.teacher_id = auth.uid()));
|
||||||
|
|
||||||
|
drop policy if exists exam_regions_service on public.exam_response_areas;
|
||||||
|
create policy exam_regions_service on public.exam_response_areas using (auth.role() = 'service_role');
|
||||||
|
drop policy if exists exam_regions_all on public.exam_response_areas;
|
||||||
|
create policy exam_regions_all on public.exam_response_areas for all to authenticated
|
||||||
|
using (exists (select 1 from public.exam_templates t
|
||||||
|
where t.id = exam_response_areas.template_id
|
||||||
|
and t.institute_id in (select public.user_institute_ids())))
|
||||||
|
with check (exists (select 1 from public.exam_templates t
|
||||||
|
where t.id = exam_response_areas.template_id and t.teacher_id = auth.uid()));
|
||||||
|
|
||||||
|
drop policy if exists exam_boundaries_service on public.exam_boundaries;
|
||||||
|
create policy exam_boundaries_service on public.exam_boundaries using (auth.role() = 'service_role');
|
||||||
|
drop policy if exists exam_boundaries_all on public.exam_boundaries;
|
||||||
|
create policy exam_boundaries_all on public.exam_boundaries for all to authenticated
|
||||||
|
using (exists (select 1 from public.exam_templates t
|
||||||
|
where t.id = exam_boundaries.template_id
|
||||||
|
and t.institute_id in (select public.user_institute_ids())))
|
||||||
|
with check (exists (select 1 from public.exam_templates t
|
||||||
|
where t.id = exam_boundaries.template_id and t.teacher_id = auth.uid()));
|
||||||
|
|
||||||
|
-- marking_batches: read = same institute (colleagues), write = owning teacher (R2.4)
|
||||||
|
drop policy if exists marking_batches_service on public.marking_batches;
|
||||||
|
create policy marking_batches_service on public.marking_batches using (auth.role() = 'service_role');
|
||||||
|
drop policy if exists marking_batches_read on public.marking_batches;
|
||||||
|
create policy marking_batches_read on public.marking_batches for select to authenticated
|
||||||
|
using (institute_id in (select public.user_institute_ids()));
|
||||||
|
drop policy if exists marking_batches_write on public.marking_batches;
|
||||||
|
create policy marking_batches_write on public.marking_batches for all to authenticated
|
||||||
|
using (teacher_id = auth.uid() and institute_id in (select public.user_institute_ids()))
|
||||||
|
with check (teacher_id = auth.uid() and institute_id in (select public.user_institute_ids()));
|
||||||
|
|
||||||
|
-- student_submissions: authz cascades from batch ownership
|
||||||
|
drop policy if exists submissions_service on public.student_submissions;
|
||||||
|
create policy submissions_service on public.student_submissions using (auth.role() = 'service_role');
|
||||||
|
drop policy if exists submissions_all on public.student_submissions;
|
||||||
|
create policy submissions_all on public.student_submissions for all to authenticated
|
||||||
|
using (exists (select 1 from public.marking_batches b
|
||||||
|
where b.id = student_submissions.batch_id and b.teacher_id = auth.uid()))
|
||||||
|
with check (exists (select 1 from public.marking_batches b
|
||||||
|
where b.id = student_submissions.batch_id and b.teacher_id = auth.uid()));
|
||||||
|
|
||||||
|
-- mark_entries: teacher (batch owner) full; student may read their own marks (R1.5, UI deferred)
|
||||||
|
drop policy if exists marks_service on public.mark_entries;
|
||||||
|
create policy marks_service on public.mark_entries using (auth.role() = 'service_role');
|
||||||
|
drop policy if exists marks_teacher_all on public.mark_entries;
|
||||||
|
create policy marks_teacher_all on public.mark_entries for all to authenticated
|
||||||
|
using (exists (select 1 from public.marking_batches b
|
||||||
|
where b.id = mark_entries.batch_id and b.teacher_id = auth.uid()))
|
||||||
|
with check (exists (select 1 from public.marking_batches b
|
||||||
|
where b.id = mark_entries.batch_id and b.teacher_id = auth.uid()));
|
||||||
|
drop policy if exists marks_student_read on public.mark_entries;
|
||||||
|
create policy marks_student_read on public.mark_entries for select to authenticated
|
||||||
|
using (exists (select 1 from public.student_submissions s
|
||||||
|
where s.id = mark_entries.submission_id and s.student_id = auth.uid()));
|
||||||
42
volumes/db/cc/73-exam-marker-regions.sql
Normal file
42
volumes/db/cc/73-exam-marker-regions.sql
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
-- 73-exam-marker-regions.sql
|
||||||
|
-- Extends the exam-marker physical model (72-exam-marker.sql) for the locked S4-9 shape taxonomy
|
||||||
|
-- (user discussion 2026-06-06; see ~/cc/ideas/2026-06-06-s4-9-design-answers-from-transcript.md).
|
||||||
|
--
|
||||||
|
-- Taxonomy: Boundary carves main Questions; a teacher draws a box around each Part; inside a Part
|
||||||
|
-- live bounded regions of several KINDS. "Band"/"span" are retired. Idempotent (IF NOT EXISTS /
|
||||||
|
-- drop-and-re-add the named CHECK). Additive on top of 72; safe to re-run.
|
||||||
|
|
||||||
|
--==========================================================================================
|
||||||
|
-- 1. exam_questions: geometry for the drawn Part box (and optional main-question region)
|
||||||
|
--==========================================================================================
|
||||||
|
-- Geometry previously lived only on response areas. A Part is now a drawn box, so the question
|
||||||
|
-- row carries its own bounds + page. Nullable: main questions are derived between their boundaries.
|
||||||
|
alter table public.exam_questions add column if not exists bounds jsonb; -- {x,y,w,h}
|
||||||
|
alter table public.exam_questions add column if not exists page int;
|
||||||
|
|
||||||
|
comment on column public.exam_questions.bounds is 'Drawn box for a Part (leaf); null for a derived main question';
|
||||||
|
comment on column public.exam_questions.page is 'Page index the Part box sits on; null for derived main questions';
|
||||||
|
|
||||||
|
--==========================================================================================
|
||||||
|
-- 2. exam_response_areas: more region kinds + context differentiation
|
||||||
|
--==========================================================================================
|
||||||
|
-- v1 keeps one generic Context but plans subject-specific differentiation later
|
||||||
|
-- (graph, chart, data_table, diagram, code_block, passage, …). Nullable now.
|
||||||
|
alter table public.exam_response_areas add column if not exists context_type text;
|
||||||
|
|
||||||
|
comment on column public.exam_response_areas.context_type is
|
||||||
|
'Optional Context differentiation (v1 generic); future: graph|chart|data_table|diagram|code_block|passage';
|
||||||
|
|
||||||
|
-- Extend the kind enum. Region kinds now:
|
||||||
|
-- response - where the student writes (uses response_form)
|
||||||
|
-- context - stimulus the question/part draws on (uses context_type)
|
||||||
|
-- question_number - bounds the printed label "01" / "2.1" (physical metadata for OCR/AI)
|
||||||
|
-- mark_area - bounds the printed marks "[2]" / "Total for Question X is N marks"
|
||||||
|
-- reference - formulae/data sheets, appendices the student uses (kept, NOT ignored)
|
||||||
|
-- furniture - margins, page numbers, blank space, decoration (explicitly excluded)
|
||||||
|
alter table public.exam_response_areas drop constraint if exists exam_response_areas_kind_check;
|
||||||
|
alter table public.exam_response_areas add constraint exam_response_areas_kind_check
|
||||||
|
check (kind in ('response','context','question_number','mark_area','reference','furniture'));
|
||||||
|
|
||||||
|
comment on column public.exam_response_areas.kind is
|
||||||
|
'response|context|question_number|mark_area|reference|furniture (see 73-exam-marker-regions.sql)';
|
||||||
112
volumes/db/cc/74-exam-marker-layout.sql
Normal file
112
volumes/db/cc/74-exam-marker-layout.sql
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
-- 74-exam-marker-layout.sql
|
||||||
|
-- S5 exam-marker layout layer + AI/manual seam provenance.
|
||||||
|
--
|
||||||
|
-- Adds the per-page layout projection that the docling auto-map pipeline writes to, plus
|
||||||
|
-- provenance columns needed to distinguish unconfirmed AI suggestions from teacher/manual data.
|
||||||
|
-- Safe/idempotent on top of 72-exam-marker.sql + 73-exam-marker-regions.sql.
|
||||||
|
|
||||||
|
--==========================================================================================
|
||||||
|
-- 1. exam_template_layout: stage-2 per-page layout layer
|
||||||
|
--==========================================================================================
|
||||||
|
create table if not exists public.exam_template_layout (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
template_id uuid not null references public.exam_templates(id) on delete cascade,
|
||||||
|
page_index int not null,
|
||||||
|
role text,
|
||||||
|
margin_left numeric,
|
||||||
|
margin_right numeric,
|
||||||
|
margin_top numeric,
|
||||||
|
margin_bottom numeric,
|
||||||
|
margins_enabled boolean not null default true,
|
||||||
|
source text not null default 'manual' check (source in ('manual','ai')),
|
||||||
|
confirmed boolean not null default true,
|
||||||
|
confidence numeric,
|
||||||
|
derivation text,
|
||||||
|
meta jsonb not null default '{}'::jsonb,
|
||||||
|
created_at timestamptz not null default timezone('utc', now()),
|
||||||
|
updated_at timestamptz not null default timezone('utc', now()),
|
||||||
|
unique (template_id, page_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table public.exam_template_layout is
|
||||||
|
'Per-template/per-page layout projection for exam-marker auto-map review: page role, nullable margins, provenance and future layout-profile metadata.';
|
||||||
|
comment on column public.exam_template_layout.role is
|
||||||
|
'Page role such as cover|question|continuation|blank|appendix; nullable when unknown/manual draft.';
|
||||||
|
comment on column public.exam_template_layout.meta is
|
||||||
|
'Forward-compatible layout metadata: columns, front-matter, appendix/blank markers, future profile_id linkage, etc.';
|
||||||
|
|
||||||
|
create index if not exists idx_exam_template_layout_template on public.exam_template_layout(template_id);
|
||||||
|
create index if not exists idx_exam_template_layout_source_confirmed on public.exam_template_layout(template_id, source, confirmed);
|
||||||
|
|
||||||
|
drop trigger if exists handle_exam_template_layout_updated_at on public.exam_template_layout;
|
||||||
|
create trigger handle_exam_template_layout_updated_at before update on public.exam_template_layout
|
||||||
|
for each row execute function public.handle_updated_at();
|
||||||
|
|
||||||
|
--==========================================================================================
|
||||||
|
-- 2. AI seam/provenance columns on existing physical template tables
|
||||||
|
--==========================================================================================
|
||||||
|
-- Existing/manual rows default to authoritative manual data. Auto-map rows must explicitly set
|
||||||
|
-- source='ai' and confirmed=false so reruns can refresh only unconfirmed AI suggestions.
|
||||||
|
alter table public.exam_questions add column if not exists source text not null default 'manual' check (source in ('manual','ai'));
|
||||||
|
alter table public.exam_questions add column if not exists confirmed boolean not null default true;
|
||||||
|
alter table public.exam_questions add column if not exists confidence numeric;
|
||||||
|
alter table public.exam_questions add column if not exists derivation text;
|
||||||
|
|
||||||
|
alter table public.exam_response_areas add column if not exists mark_subtype text;
|
||||||
|
alter table public.exam_response_areas add column if not exists derivation text;
|
||||||
|
|
||||||
|
alter table public.exam_response_areas drop constraint if exists exam_response_areas_mark_subtype_check;
|
||||||
|
alter table public.exam_response_areas add constraint exam_response_areas_mark_subtype_check
|
||||||
|
check (
|
||||||
|
mark_subtype is null
|
||||||
|
or (kind = 'mark_area' and mark_subtype in ('part_marks','question_total','grader_box'))
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.exam_boundaries add column if not exists confidence numeric;
|
||||||
|
alter table public.exam_boundaries add column if not exists derivation text;
|
||||||
|
|
||||||
|
-- Confidence values are normalized model/layout confidence scores when present.
|
||||||
|
alter table public.exam_template_layout drop constraint if exists exam_template_layout_confidence_check;
|
||||||
|
alter table public.exam_template_layout add constraint exam_template_layout_confidence_check
|
||||||
|
check (confidence is null or (confidence >= 0 and confidence <= 1));
|
||||||
|
alter table public.exam_questions drop constraint if exists exam_questions_confidence_check;
|
||||||
|
alter table public.exam_questions add constraint exam_questions_confidence_check
|
||||||
|
check (confidence is null or (confidence >= 0 and confidence <= 1));
|
||||||
|
alter table public.exam_response_areas drop constraint if exists exam_response_areas_confidence_check;
|
||||||
|
alter table public.exam_response_areas add constraint exam_response_areas_confidence_check
|
||||||
|
check (confidence is null or (confidence >= 0 and confidence <= 1));
|
||||||
|
alter table public.exam_boundaries drop constraint if exists exam_boundaries_confidence_check;
|
||||||
|
alter table public.exam_boundaries add constraint exam_boundaries_confidence_check
|
||||||
|
check (confidence is null or (confidence >= 0 and confidence <= 1));
|
||||||
|
|
||||||
|
comment on column public.exam_questions.derivation is
|
||||||
|
'Lightweight provenance such as ai|manual|boundary_derived|margin_derived|layout_derived.';
|
||||||
|
comment on column public.exam_response_areas.mark_subtype is
|
||||||
|
'Only meaningful for kind=mark_area: part_marks|question_total|grader_box. Grader boxes are persisted only when explicitly detected/provided.';
|
||||||
|
comment on column public.exam_response_areas.derivation is
|
||||||
|
'Lightweight provenance such as ai|manual|detected|layout_derived.';
|
||||||
|
comment on column public.exam_boundaries.derivation is
|
||||||
|
'Lightweight provenance such as ai|manual|band_derived.';
|
||||||
|
|
||||||
|
create index if not exists idx_exam_questions_source_confirmed on public.exam_questions(template_id, source, confirmed);
|
||||||
|
create index if not exists idx_exam_regions_source_confirmed on public.exam_response_areas(template_id, source, confirmed);
|
||||||
|
create index if not exists idx_exam_boundaries_source_confirmed on public.exam_boundaries(template_id, source, confirmed);
|
||||||
|
|
||||||
|
--==========================================================================================
|
||||||
|
-- 3. RLS — mirror exam_templates ownership/institute scope via the owning template
|
||||||
|
--==========================================================================================
|
||||||
|
alter table public.exam_template_layout enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists exam_template_layout_service on public.exam_template_layout;
|
||||||
|
create policy exam_template_layout_service on public.exam_template_layout
|
||||||
|
using (auth.role() = 'service_role');
|
||||||
|
|
||||||
|
drop policy if exists exam_template_layout_all on public.exam_template_layout;
|
||||||
|
create policy exam_template_layout_all on public.exam_template_layout for all to authenticated
|
||||||
|
using (exists (select 1 from public.exam_templates t
|
||||||
|
where t.id = exam_template_layout.template_id
|
||||||
|
and t.institute_id in (select public.user_institute_ids())))
|
||||||
|
with check (exists (select 1 from public.exam_templates t
|
||||||
|
where t.id = exam_template_layout.template_id
|
||||||
|
and t.teacher_id = auth.uid()
|
||||||
|
and t.institute_id in (select public.user_institute_ids())));
|
||||||
51
volumes/db/cc/75-files-gc-protect-delete.sql
Normal file
51
volumes/db/cc/75-files-gc-protect-delete.sql
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
-- Allow our file-GC helpers to satisfy Supabase storage.protect_delete
|
||||||
|
-- without weakening the managed storage trigger for arbitrary raw deletes.
|
||||||
|
--
|
||||||
|
-- Supabase storage.protect_delete permits direct storage.objects deletes only
|
||||||
|
-- when the transaction-local GUC storage.allow_delete_query is 'true'. The
|
||||||
|
-- Storage API sets that GUC for its own deletes. public.files GC is our
|
||||||
|
-- trigger-side cleanup path, so scope the same GUC to these SECURITY DEFINER
|
||||||
|
-- helpers immediately before their storage.objects DELETE statements.
|
||||||
|
|
||||||
|
create or replace function public._delete_storage_objects(p_bucket text, p_path text)
|
||||||
|
returns void
|
||||||
|
language plpgsql security definer
|
||||||
|
set search_path to 'public', 'storage'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if p_bucket is null or p_path is null then
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
perform set_config('storage.allow_delete_query', 'true', true);
|
||||||
|
|
||||||
|
delete from storage.objects where bucket_id = p_bucket and name = p_path;
|
||||||
|
delete from storage.objects where bucket_id = p_bucket and name like p_path || '/%';
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public._delete_storage_prefix(p_bucket text, p_prefix text)
|
||||||
|
returns void
|
||||||
|
language plpgsql security definer
|
||||||
|
set search_path to 'public', 'storage'
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if p_bucket is null or p_prefix is null then
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
perform set_config('storage.allow_delete_query', 'true', true);
|
||||||
|
|
||||||
|
-- Delete any objects whose name starts with the prefix + '/'
|
||||||
|
delete from storage.objects where bucket_id = p_bucket and name like p_prefix || '/%';
|
||||||
|
-- In case an object exists exactly at the prefix (rare but safe)
|
||||||
|
delete from storage.objects where bucket_id = p_bucket and name = p_prefix;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- These helpers are intended for our SECURITY DEFINER GC triggers/helpers, not
|
||||||
|
-- as a general SQL delete API for application roles.
|
||||||
|
revoke all on function public._delete_storage_objects(text, text) from public;
|
||||||
|
revoke all on function public._delete_storage_prefix(text, text) from public;
|
||||||
|
revoke all on function public._delete_storage_objects(text, text) from anon, authenticated;
|
||||||
|
revoke all on function public._delete_storage_prefix(text, text) from anon, authenticated;
|
||||||
88
volumes/db/cc/76-cabinet-memberships-rls-definer.sql
Normal file
88
volumes/db/cc/76-cabinet-memberships-rls-definer.sql
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
-- 76-cabinet-memberships-rls-definer.sql
|
||||||
|
-- Fix cabinet_memberships/file_cabinets/files recursive RLS.
|
||||||
|
--
|
||||||
|
-- The original cabinet membership policies joined file_cabinets directly, while
|
||||||
|
-- the file_cabinets/files membership policies joined cabinet_memberships
|
||||||
|
-- directly. Under an authenticated as-user SELECT this creates an RLS cycle and
|
||||||
|
-- PostgreSQL raises 42P17 (infinite recursion detected in policy for relation
|
||||||
|
-- "cabinet_memberships").
|
||||||
|
--
|
||||||
|
-- SECURITY DEFINER helpers evaluate the ownership/membership checks as the
|
||||||
|
-- function owner (bypassing the inner RLS checks) while still keying the result
|
||||||
|
-- to auth.uid(), matching the class-management helper pattern in 71.
|
||||||
|
--
|
||||||
|
-- The existing public tables are owned by supabase_admin; Supabase migrations
|
||||||
|
-- run as that table owner on dev/prod so the SECURITY DEFINER helpers are owned
|
||||||
|
-- by a role that bypasses the inner RLS checks.
|
||||||
|
|
||||||
|
create or replace function public.is_cabinet_owner(p_cabinet uuid)
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select exists (
|
||||||
|
select 1
|
||||||
|
from public.file_cabinets c
|
||||||
|
where c.id = p_cabinet
|
||||||
|
and c.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.is_cabinet_member(p_cabinet uuid)
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select exists (
|
||||||
|
select 1
|
||||||
|
from public.cabinet_memberships m
|
||||||
|
where m.cabinet_id = p_cabinet
|
||||||
|
and m.profile_id = auth.uid()
|
||||||
|
)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Keep function execution available to the roles used by RLS policies. The
|
||||||
|
-- functions disclose only a boolean about the caller's own auth.uid() state.
|
||||||
|
revoke all on function public.is_cabinet_owner(uuid) from public, anon;
|
||||||
|
revoke all on function public.is_cabinet_member(uuid) from public, anon;
|
||||||
|
grant execute on function public.is_cabinet_owner(uuid) to authenticated, service_role;
|
||||||
|
grant execute on function public.is_cabinet_member(uuid) to authenticated, service_role;
|
||||||
|
|
||||||
|
-- Re-declare cabinet_memberships policies without direct file_cabinets subqueries.
|
||||||
|
drop policy if exists cm_read_self_or_owner on public.cabinet_memberships;
|
||||||
|
create policy cm_read_self_or_owner on public.cabinet_memberships
|
||||||
|
for select to authenticated
|
||||||
|
using (profile_id = auth.uid() or public.is_cabinet_owner(cabinet_id));
|
||||||
|
|
||||||
|
drop policy if exists cm_insert_by_owner on public.cabinet_memberships;
|
||||||
|
create policy cm_insert_by_owner on public.cabinet_memberships
|
||||||
|
for insert to authenticated
|
||||||
|
with check (public.is_cabinet_owner(cabinet_id));
|
||||||
|
|
||||||
|
drop policy if exists cm_update_by_owner on public.cabinet_memberships;
|
||||||
|
create policy cm_update_by_owner on public.cabinet_memberships
|
||||||
|
for update to authenticated
|
||||||
|
using (public.is_cabinet_owner(cabinet_id))
|
||||||
|
with check (public.is_cabinet_owner(cabinet_id));
|
||||||
|
|
||||||
|
drop policy if exists cm_delete_by_owner on public.cabinet_memberships;
|
||||||
|
create policy cm_delete_by_owner on public.cabinet_memberships
|
||||||
|
for delete to authenticated
|
||||||
|
using (public.is_cabinet_owner(cabinet_id));
|
||||||
|
|
||||||
|
-- Re-declare membership-based cabinet/file read policies without direct
|
||||||
|
-- cabinet_memberships subqueries, so selecting cabinets/files does not recurse
|
||||||
|
-- back through cabinet_memberships RLS.
|
||||||
|
drop policy if exists "User can access cabinets via membership" on public.file_cabinets;
|
||||||
|
create policy "User can access cabinets via membership" on public.file_cabinets
|
||||||
|
for select to authenticated
|
||||||
|
using (public.is_cabinet_member(id));
|
||||||
|
|
||||||
|
drop policy if exists "User can access files via membership" on public.files;
|
||||||
|
create policy "User can access files via membership" on public.files
|
||||||
|
for select to authenticated
|
||||||
|
using (public.is_cabinet_member(cabinet_id));
|
||||||
108
volumes/db/cc/77-storage-taxonomy-rls.sql
Normal file
108
volumes/db/cc/77-storage-taxonomy-rls.sql
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
-- 77-storage-taxonomy-rls.sql
|
||||||
|
-- RLS for taxonomy storage buckets. cc.examboards intentionally gets no
|
||||||
|
-- direct object-read policy: exam-board PDF bytes are exposed only through
|
||||||
|
-- API-issued, time-limited signed URLs.
|
||||||
|
|
||||||
|
create or replace function public.is_platform_admin()
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select exists (
|
||||||
|
select 1
|
||||||
|
from public.admin_profiles ap
|
||||||
|
where ap.id = auth.uid()
|
||||||
|
and coalesce(ap.is_super_admin, false)
|
||||||
|
)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.is_platform_admin() to authenticated;
|
||||||
|
|
||||||
|
-- Use the first path segment of cc.institutes objects as the institute id.
|
||||||
|
-- Expected layout: cc.institutes/<institute_uuid>/...
|
||||||
|
create or replace function public.storage_object_first_path_segment_uuid(p_name text)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
immutable
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_segment text;
|
||||||
|
begin
|
||||||
|
v_segment := split_part(coalesce(p_name, ''), '/', 1);
|
||||||
|
if v_segment = '' then
|
||||||
|
return null;
|
||||||
|
end if;
|
||||||
|
begin
|
||||||
|
return v_segment::uuid;
|
||||||
|
exception when invalid_text_representation then
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.storage_object_first_path_segment_uuid(text) to authenticated;
|
||||||
|
|
||||||
|
-- Exam-board catalogue rows are authenticated metadata. PDF bytes remain
|
||||||
|
-- protected separately in cc.examboards storage and require signed URLs.
|
||||||
|
alter table public.eb_specifications enable row level security;
|
||||||
|
alter table public.eb_exams enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists "eb_specifications read by authenticated users" on public.eb_specifications;
|
||||||
|
drop policy if exists "eb_exams read by authenticated users" on public.eb_exams;
|
||||||
|
|
||||||
|
create policy "eb_specifications read by authenticated users"
|
||||||
|
on public.eb_specifications
|
||||||
|
for select
|
||||||
|
to authenticated
|
||||||
|
using (true);
|
||||||
|
|
||||||
|
create policy "eb_exams read by authenticated users"
|
||||||
|
on public.eb_exams
|
||||||
|
for select
|
||||||
|
to authenticated
|
||||||
|
using (true);
|
||||||
|
|
||||||
|
alter table storage.objects enable row level security;
|
||||||
|
|
||||||
|
-- Replace only the policies owned by this migration. The absence of any
|
||||||
|
-- cc.examboards SELECT policy is the deny-by-default posture for raw reads.
|
||||||
|
drop policy if exists "cc.admin objects read by platform admins" on storage.objects;
|
||||||
|
drop policy if exists "cc.institutes objects read by institute members" on storage.objects;
|
||||||
|
drop policy if exists "cc.public objects read by authenticated users" on storage.objects;
|
||||||
|
|
||||||
|
create policy "cc.admin objects read by platform admins"
|
||||||
|
on storage.objects
|
||||||
|
for select
|
||||||
|
to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'cc.admin'
|
||||||
|
and public.is_platform_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy "cc.institutes objects read by institute members"
|
||||||
|
on storage.objects
|
||||||
|
for select
|
||||||
|
to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'cc.institutes'
|
||||||
|
and public.storage_object_first_path_segment_uuid(name) in (
|
||||||
|
select public.user_institute_ids()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy "cc.public objects read by authenticated users"
|
||||||
|
on storage.objects
|
||||||
|
for select
|
||||||
|
to authenticated
|
||||||
|
using (bucket_id = 'cc.public');
|
||||||
|
|
||||||
|
comment on policy "cc.admin objects read by platform admins" on storage.objects
|
||||||
|
is 'cc.admin raw reads are limited to platform admins.';
|
||||||
|
comment on policy "cc.institutes objects read by institute members" on storage.objects
|
||||||
|
is 'cc.institutes raw reads are scoped by leading path segment institute uuid.';
|
||||||
|
comment on policy "cc.public objects read by authenticated users" on storage.objects
|
||||||
|
is 'cc.public raw reads are available to any authenticated user.';
|
||||||
|
comment on table storage.objects
|
||||||
|
is 'Classroom Copilot taxonomy buckets use RLS; cc.examboards raw reads stay denied and are served through API signed URLs.';
|
||||||
Loading…
x
Reference in New Issue
Block a user