Compare commits
1 Commits
main
...
archive/ag
| Author | SHA1 | Date | |
|---|---|---|---|
| 02f68387e8 |
@ -355,17 +355,16 @@ 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
|
||||||
# ClassroomCopilot changes
|
# Classroom Copilot consolidated application schema and deterministic dev seed.
|
||||||
- ./volumes/db/cc/61-core-schema.sql:/docker-entrypoint-initdb.d/migrations/61-core-schema.sql:Z
|
# Keep this chain ordered; GAIS reference tables are schema-only here, while
|
||||||
- ./volumes/db/cc/62-functions-triggers.sql:/docker-entrypoint-initdb.d/migrations/62-functions-triggers.sql:Z
|
# full GAIS open-data bulk loads remain outside the small dev seed.
|
||||||
- ./volumes/db/cc/63-storage-policies.sql:/docker-entrypoint-initdb.d/migrations/63-storage-policies.sql:Z
|
- ./volumes/db/cc/61-gais-reference.sql:/docker-entrypoint-initdb.d/migrations/61-gais-reference.sql:Z
|
||||||
- ./volumes/db/cc/64-initial-admin.sql:/docker-entrypoint-initdb.d/migrations/64-initial-admin.sql:Z
|
- ./volumes/db/cc/62-application-schema.sql:/docker-entrypoint-initdb.d/migrations/62-application-schema.sql:Z
|
||||||
- ./volumes/db/cc/65-filesystem-augments.sql:/docker-entrypoint-initdb.d/migrations/65-filesystem-augments.sql:Z
|
- ./volumes/db/cc/63-academic-calendar.sql:/docker-entrypoint-initdb.d/migrations/63-academic-calendar.sql:Z
|
||||||
- ./volumes/db/cc/66-rls-policies.sql:/docker-entrypoint-initdb.d/migrations/66-rls-policies.sql:Z
|
- ./volumes/db/cc/64-extended-schema.sql:/docker-entrypoint-initdb.d/migrations/64-extended-schema.sql:Z
|
||||||
- ./volumes/db/cc/67-vectors.sql:/docker-entrypoint-initdb.d/migrations/67-vectors.sql:Z
|
- ./volumes/db/cc/65-phase-c.sql:/docker-entrypoint-initdb.d/migrations/65-phase-c.sql:Z
|
||||||
- ./volumes/db/cc/68-cabinet-memberships.sql:/docker-entrypoint-initdb.d/migrations/68-cabinet-memberships.sql:Z
|
- ./volumes/db/cc/66-taught-lessons-nullable.sql:/docker-entrypoint-initdb.d/migrations/66-taught-lessons-nullable.sql:Z
|
||||||
- ./volumes/db/cc/69-gc-prefix-cleanup.sql:/docker-entrypoint-initdb.d/migrations/69-gc-prefix-cleanup.sql:Z
|
- ./volumes/db/cc/67-dev-seed.sql:/docker-entrypoint-initdb.d/migrations/67-dev-seed.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
|
||||||
|
|||||||
105
docs/migrations-and-dev-seed.md
Normal file
105
docs/migrations-and-dev-seed.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# 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.
|
||||||
@ -1,364 +0,0 @@
|
|||||||
--[ 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();
|
|
||||||
71
volumes/db/cc/61-gais-reference.sql
Normal file
71
volumes/db/cc/61-gais-reference.sql
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
-- 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);
|
||||||
763
volumes/db/cc/62-application-schema.sql
Normal file
763
volumes/db/cc/62-application-schema.sql
Normal file
@ -0,0 +1,763 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 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()));
|
||||||
@ -1,191 +0,0 @@
|
|||||||
--[ 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();
|
|
||||||
225
volumes/db/cc/63-academic-calendar.sql
Normal file
225
volumes/db/cc/63-academic-calendar.sql
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 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');
|
||||||
@ -1,20 +0,0 @@
|
|||||||
-- 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
|
|
||||||
266
volumes/db/cc/64-extended-schema.sql
Normal file
266
volumes/db/cc/64-extended-schema.sql
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 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');
|
||||||
@ -1,20 +0,0 @@
|
|||||||
-- 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
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
-- 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();
|
|
||||||
|
|
||||||
|
|
||||||
53
volumes/db/cc/65-phase-c.sql
Normal file
53
volumes/db/cc/65-phase-c.sql
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 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.
|
||||||
@ -1,84 +0,0 @@
|
|||||||
-- 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
|
|
||||||
|
|
||||||
|
|
||||||
15
volumes/db/cc/66-taught-lessons-nullable.sql
Normal file
15
volumes/db/cc/66-taught-lessons-nullable.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 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;
|
||||||
220
volumes/db/cc/67-dev-seed.sql
Normal file
220
volumes/db/cc/67-dev-seed.sql
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 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 $$;
|
||||||
@ -1,79 +0,0 @@
|
|||||||
-- 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)
|
|
||||||
$$;
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
-- 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()
|
|
||||||
));
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
-- 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
|
|
||||||
$$;
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
-- 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"
|
|
||||||
-- }
|
|
||||||
Loading…
x
Reference in New Issue
Block a user