24 changed files with 2528 additions and 1049 deletions
+21
View File
@@ -0,0 +1,21 @@
name: supabase-ci
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate Docker Compose syntax
run: docker compose -f docker-compose.yml config >/tmp/supabase-compose.rendered.yml
- name: Build Supabase MCP image
working-directory: selfhosted-supabase-mcp
run: docker build -t supabase-mcp-ci:${{ github.sha }} .
+42 -16
View File
@@ -1,22 +1,48 @@
# Environment files
# Python
__pycache__/
*.py[cod]
*.so
build/
dist/
eggs/
*.egg-info/
.venv/
venv/
# Node
node_modules/
/dist
/build
# Environment files (never commit secrets)
.env
.env.*
!.env.example
.env.local
.env.*.local
.archive/
# IDE
.vscode/
.idea/
# Docker volume RUNTIME data (large binary/runtime files - not schema SQL)
volumes/db-data/
volumes/storage/
volumes/pooler/
volumes/logs/
# Backup files
*.bak
*.bak.*
backups/
# OS files
.DS_Store
Thumbs.db
# Logs
logs/
*.log
logs/
queue_workers.log
# Large files
*.csv
*.xlsx
*.sqlite
*.db
# Docker
docker-compose.override.yml
# Supabase local development
.gitignore
.DS_Store
*.log
*.tmp
+124
View File
@@ -0,0 +1,124 @@
1. Data Model Questions
Q1: Class vs Group Relationship
You say, "Current groups are generic." but there is no class implementation in supabase yet.
Q2: Student-Teacher Relationships
Should students be explicitly enrolled in classes by teachers, or:
Can any student from the same institution join any class?
Only teachers can add or remove students in classes that belong to them. Classes can belong to multiple teachers from the same institute. We will add partnering instution access later (e.g. for teacher supply agencies to teach classes). Only teachers of the class (and admins) can add or remove students to classes, and manage enrollment requests.
Are there enrollment requests/approvals? Yes. Students from any institute can enrol to ad-hoc lessons/timetables. We forgot to add that ad-hoc timetables are created by normal institute teachers and partnering supply teacher institutes (institutes that are designated as supply haven't been implemented yet - see below for details).
Can students be in multiple classes with the same teacher? Yes. Some classes may be designated as clubs, extra-curricular or other 'extra' type classes depending on the timetable.
Q3: Timetable Scope
A single teacher may teach multiple institutes over time. Should timetables be:
A) Per teacher (spanning all their institutes)?
B) Per teacher-per institute combination?
C) Institute-wide with teacher assignments?
We are focussin two types of timetabling: Ad-hoc and Recurring. We will implement the following features in Q1:
A) Ad-hoc Timetable Creation: Supply teachers and school institute teachers crete ad-hoc lessons/timetables. Ad-hoc timetables are simple and should be entered manually.
B) Recurring Timetable Creation: Recurring timetables are created by normal institute teachers. These are created by supplying details about the nature of the timetable e.g. single/bi weekly, applicable days, number of lessons/blocks in the day, times of the lessons, classes for those lessons, possibly other details such as rooms etc. For a supply teacher they may indicate the insitute that they are teaching at or not (we will need to link partnering supply teacher institutes later).
2. Lesson & Whiteboard Association
Q4: Whiteboard Lifecycle
Should each lesson instance have exactly one whiteboard (created on first open)?
If the timetable is ad-hoc then yes.
Can a lesson have multiple whiteboards (e.g., prep notes + lesson board)? Yes this sounds like a good idea but only for recurring timetables belonging to an institute teachers.
Should whiteboards persist when lessons are rescheduled/cancelled?
Canelling/rescheduling lessons is only available to institute teachers with recurring timetables. If a lesson is rescheduled/modified then we should keep the whiteboard associated with the lesson. If it is cancelled then we should delete the whiteboard.
Q5: Student Access to Whiteboards
Real-time collaborative during lesson only? Yes, real-time collaboration is only available during the live lessons only.
Read-only access after lesson completion? Yes, after completion students should be able to get a read-only version of the same whiteboard (they will be able to save it to their own area when modified).
Full editing permissions for students? For now we allow full editing permissions for the student. There is a presentation mode that we are working on and eventually we will add the ability for teachers to manage student interaction with more granularity.
3. Scheduling Flexibility
Q6: Ad-hoc vs Recurring
The requirement mentions both minimal ad-hoc and complex recurring timetables. Should we:
C) Build recurrence gradually (start with ad-hoc, add recurrence later). We will implement RRULES later.
Q4: Timetable Draft States
Should timetables have lifecycle states?
simple active/inactive for now, more complex states when adding rrecurrence.
4. Technical Implementation
Q8: Supabase Schema Management
Current migration files are numbered (001_cc_schema.sql, etc.). For new features:
Switch to timestamp-based naming (20250225120000_...)
Q9: Frontend Architecture
Should I create new route structures like:
/timetable - main timetabling interface
/classes - class management
/lessons/:id - individual lesson views
Create NEW ROUTE STRUCTURES.
5. Feature Scope & Phasing
Q10: MVP Scope Priority
From the full feature set, what's the absolute minimum for first release?
My understanding:
✅ Teacher creates classes with students
✅ Teacher creates simple timetable (single repeating pattern)
✅ Lessons linked to whiteboards
✅ Students access whiteboards in real-time
Out of MVP?
Multi-week rotating schedules? Later
Full academic year with holidays? Later
Exam scheduling? Later
Report generation? Later
Duty blocks? Later
Break notes whiteboards? Later
Q11: Competitive Features
Are there any existing products we should study for UX patterns?
Google Classroom?
Microsoft Teams/Classroom?
Yes, use the research tool to research these.
Specialized tools like Firefly, iSAMS, SIMS? Not yet. We will integrate with these later.
6. Business Logic Questions
Q12: Lesson Modifications
When a recurring lesson is modified:
D) All three options? Yes, do all three options below.
Edit this instance only (create exception)?
Edit this and future instances?
Edit all instances in series?
Q13: Class Transfers
Can students move between classes mid-term? How should their whiteboard history be handled? No, we will handle this later.
Q14: Notifications
We will not implement a notification system yet.
Q15: Resource Attachments
Lesson resources mentioned as future consideration. Should we:
A) Not implement now, leave schema extensible. We have started a file management system already that we will build in.
+10 -11
View File
@@ -355,17 +355,16 @@ services:
- ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/59-logs.sql:Z
# Changes required for Pooler support
- ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/59-pooler.sql:Z
# ClassroomCopilot changes
- ./volumes/db/cc/61-core-schema.sql:/docker-entrypoint-initdb.d/migrations/61-core-schema.sql:Z
- ./volumes/db/cc/62-functions-triggers.sql:/docker-entrypoint-initdb.d/migrations/62-functions-triggers.sql:Z
- ./volumes/db/cc/63-storage-policies.sql:/docker-entrypoint-initdb.d/migrations/63-storage-policies.sql:Z
- ./volumes/db/cc/64-initial-admin.sql:/docker-entrypoint-initdb.d/migrations/64-initial-admin.sql:Z
- ./volumes/db/cc/65-filesystem-augments.sql:/docker-entrypoint-initdb.d/migrations/65-filesystem-augments.sql:Z
- ./volumes/db/cc/66-rls-policies.sql:/docker-entrypoint-initdb.d/migrations/66-rls-policies.sql:Z
- ./volumes/db/cc/67-vectors.sql:/docker-entrypoint-initdb.d/migrations/67-vectors.sql:Z
- ./volumes/db/cc/68-cabinet-memberships.sql:/docker-entrypoint-initdb.d/migrations/68-cabinet-memberships.sql:Z
- ./volumes/db/cc/69-gc-prefix-cleanup.sql:/docker-entrypoint-initdb.d/migrations/69-gc-prefix-cleanup.sql:Z
- ./volumes/db/cc/70-add-directory-support.sql:/docker-entrypoint-initdb.d/migrations/70-add-directory-support.sql:Z
# Classroom Copilot consolidated application schema and deterministic dev seed.
# Keep this chain ordered; GAIS reference tables are schema-only here, while
# full GAIS open-data bulk loads remain outside the small dev seed.
- ./volumes/db/cc/61-gais-reference.sql:/docker-entrypoint-initdb.d/migrations/61-gais-reference.sql:Z
- ./volumes/db/cc/62-application-schema.sql:/docker-entrypoint-initdb.d/migrations/62-application-schema.sql:Z
- ./volumes/db/cc/63-academic-calendar.sql:/docker-entrypoint-initdb.d/migrations/63-academic-calendar.sql:Z
- ./volumes/db/cc/64-extended-schema.sql:/docker-entrypoint-initdb.d/migrations/64-extended-schema.sql:Z
- ./volumes/db/cc/65-phase-c.sql:/docker-entrypoint-initdb.d/migrations/65-phase-c.sql:Z
- ./volumes/db/cc/66-taught-lessons-nullable.sql:/docker-entrypoint-initdb.d/migrations/66-taught-lessons-nullable.sql:Z
- ./volumes/db/cc/67-dev-seed.sql:/docker-entrypoint-initdb.d/migrations/67-dev-seed.sql:Z
# PGDATA directory - persists database files between restarts
- ./volumes/db-data:/var/lib/postgresql/data:Z
# Use named volume to persist pgsodium decryption key between restarts
+105
View 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.
@@ -0,0 +1,242 @@
-- Migration: Add application-specific functions
-- This migration adds 9 functions that exist on the live database but were
-- not included in earlier schema migrations.
-- =============================================================================
-- 1. setup_initial_admin
-- Sets up an admin user by updating their user_type and username.
-- SECURITY DEFINER: Must be run as service_role or superuser.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.setup_initial_admin(admin_email text)
RETURNS json
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
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;
$function$;
-- =============================================================================
-- 2. is_admin
-- Returns true if the current user has admin role.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.is_admin()
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
AS $function$
select coalesce(
(select true
from public.profiles
where id = auth.uid()
and user_type = 'admin'),
false
);
$function$;
-- =============================================================================
-- 3. is_super_admin
-- Alias for is_admin (same logic).
-- =============================================================================
CREATE OR REPLACE FUNCTION public.is_super_admin()
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
AS $function$
select coalesce(
(select true
from public.profiles
where id = auth.uid()
and user_type = 'admin'),
false
);
$function$;
-- =============================================================================
-- 4. check_db_ready
-- Health check: verifies essential schemas, tables, and RLS are in place.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.check_db_ready()
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
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;
$function$;
-- =============================================================================
-- 5. match_file_vectors
-- Vector similarity search over file artefacts.
-- NOTE: Requires the `file_vectors` table to exist (vector extension needed).
-- This function was created for a table that may not have been migrated yet.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.match_file_vectors(
filter jsonb,
match_count integer,
query_embedding 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 $function$
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)
$function$;
-- =============================================================================
-- 6. set_completed_at
-- Trigger function: sets completed_at when status changes to 'completed'.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.set_completed_at()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
begin
if NEW.status = 'completed' and OLD.status != 'completed' then
NEW.completed_at = now();
end if;
return NEW;
end;
$function$;
-- =============================================================================
-- 7. handle_updated_at
-- Trigger function: sets updated_at to current UTC time on UPDATE.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.handle_updated_at()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
begin
new.updated_at = timezone('utc'::text, now());
return new;
end;
$function$;
-- =============================================================================
-- 8. update_updated_at_column
-- Alternative trigger function: sets updated_at to NOW() on UPDATE.
-- (Duplicate of handle_updated_at with slightly different syntax)
-- =============================================================================
CREATE OR REPLACE FUNCTION public.update_updated_at_column()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$function$;
-- =============================================================================
-- 9. exec_sql
-- Executes arbitrary SQL. SECURITY DEFINER — use with extreme caution.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.exec_sql(query text)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
begin
execute query;
end;
$function$;
+371 -7
View File
@@ -44,6 +44,41 @@ services:
- /auth/v1/verify
plugins:
- name: cors
config:
origins:
- "https://app.classroomcopilot.ai"
- "https://api.classroomcopilot.ai"
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- HEAD
headers:
- Accept
- Accept-Profile
- Authorization
- Content-Type
- Content-Profile
- X-Client-Info
- X-Supabase-Api-Version
- apikey
- Prefer
- Range
- Range-Unit
- X-Requested-With
exposed_headers:
- Content-Range
- Content-Profile
- X-Total-Count
credentials: true
max_age: 3600
preflight_continue: false
- name: auth-v1-open-callback
url: http://auth:9999/callback
routes:
@@ -53,6 +88,41 @@ services:
- /auth/v1/callback
plugins:
- name: cors
config:
origins:
- "https://app.classroomcopilot.ai"
- "https://api.classroomcopilot.ai"
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- HEAD
headers:
- Accept
- Accept-Profile
- Authorization
- Content-Type
- Content-Profile
- X-Client-Info
- X-Supabase-Api-Version
- apikey
- Prefer
- Range
- Range-Unit
- X-Requested-With
exposed_headers:
- Content-Range
- Content-Profile
- X-Total-Count
credentials: true
max_age: 3600
preflight_continue: false
- name: auth-v1-open-authorize
url: http://auth:9999/authorize
routes:
@@ -62,7 +132,41 @@ services:
- /auth/v1/authorize
plugins:
- name: cors
config:
origins:
- "https://app.classroomcopilot.ai"
- "https://api.classroomcopilot.ai"
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- HEAD
headers:
- Accept
- Accept-Profile
- Authorization
- Content-Type
- Content-Profile
- X-Client-Info
- X-Supabase-Api-Version
- apikey
- Prefer
- Range
- Range-Unit
- X-Requested-With
exposed_headers:
- Content-Range
- Content-Profile
- X-Total-Count
credentials: true
max_age: 3600
preflight_continue: false
## Secure Auth routes
- name: auth-v1
_comment: 'GoTrue: /auth/v1/* -> http://auth:9999/*'
@@ -74,6 +178,41 @@ services:
- /auth/v1/
plugins:
- name: cors
config:
origins:
- "https://app.classroomcopilot.ai"
- "https://api.classroomcopilot.ai"
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- HEAD
headers:
- Accept
- Accept-Profile
- Authorization
- Content-Type
- Content-Profile
- X-Client-Info
- X-Supabase-Api-Version
- apikey
- Prefer
- Range
- Range-Unit
- X-Requested-With
exposed_headers:
- Content-Range
- Content-Profile
- X-Total-Count
credentials: true
max_age: 3600
preflight_continue: false
- name: key-auth
config:
hide_credentials: false
@@ -95,6 +234,41 @@ services:
- /rest/v1/
plugins:
- name: cors
config:
origins:
- "https://app.classroomcopilot.ai"
- "https://api.classroomcopilot.ai"
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- HEAD
headers:
- Accept
- Accept-Profile
- Authorization
- Content-Type
- Content-Profile
- X-Client-Info
- X-Supabase-Api-Version
- apikey
- Prefer
- Range
- Range-Unit
- X-Requested-With
exposed_headers:
- Content-Range
- Content-Profile
- X-Total-Count
credentials: true
max_age: 3600
preflight_continue: false
- name: key-auth
config:
hide_credentials: true
@@ -116,6 +290,41 @@ services:
- /graphql/v1
plugins:
- name: cors
config:
origins:
- "https://app.classroomcopilot.ai"
- "https://api.classroomcopilot.ai"
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- HEAD
headers:
- Accept
- Accept-Profile
- Authorization
- Content-Type
- Content-Profile
- X-Client-Info
- X-Supabase-Api-Version
- apikey
- Prefer
- Range
- Range-Unit
- X-Requested-With
exposed_headers:
- Content-Range
- Content-Profile
- X-Total-Count
credentials: true
max_age: 3600
preflight_continue: false
- name: key-auth
config:
hide_credentials: true
@@ -143,6 +352,41 @@ services:
- /realtime/v1/
plugins:
- name: cors
config:
origins:
- "https://app.classroomcopilot.ai"
- "https://api.classroomcopilot.ai"
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- HEAD
headers:
- Accept
- Accept-Profile
- Authorization
- Content-Type
- Content-Profile
- X-Client-Info
- X-Supabase-Api-Version
- apikey
- Prefer
- Range
- Range-Unit
- X-Requested-With
exposed_headers:
- Content-Range
- Content-Profile
- X-Total-Count
credentials: true
max_age: 3600
preflight_continue: false
- name: key-auth
config:
hide_credentials: false
@@ -163,6 +407,41 @@ services:
- /realtime/v1/api
plugins:
- name: cors
config:
origins:
- "https://app.classroomcopilot.ai"
- "https://api.classroomcopilot.ai"
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- HEAD
headers:
- Accept
- Accept-Profile
- Authorization
- Content-Type
- Content-Profile
- X-Client-Info
- X-Supabase-Api-Version
- apikey
- Prefer
- Range
- Range-Unit
- X-Requested-With
exposed_headers:
- Content-Range
- Content-Profile
- X-Total-Count
credentials: true
max_age: 3600
preflight_continue: false
- name: key-auth
config:
hide_credentials: false
@@ -183,7 +462,42 @@ services:
- /storage/v1/
plugins:
- name: cors
config:
origins:
- "https://app.classroomcopilot.ai"
- "https://api.classroomcopilot.ai"
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- HEAD
headers:
- Accept
- Accept-Profile
- Authorization
- Content-Type
- Content-Profile
- X-Client-Info
- X-Supabase-Api-Version
- apikey
- Prefer
- Range
- Range-Unit
- X-Requested-With
- x-upsert
exposed_headers:
- Content-Range
- Content-Profile
- X-Total-Count
credentials: true
max_age: 3600
preflight_continue: false
## Edge Functions routes
- name: functions-v1
_comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*'
@@ -195,7 +509,41 @@ services:
- /functions/v1/
plugins:
- name: cors
config:
origins:
- "https://app.classroomcopilot.ai"
- "https://api.classroomcopilot.ai"
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- HEAD
headers:
- Accept
- Accept-Profile
- Authorization
- Content-Type
- Content-Profile
- X-Client-Info
- X-Supabase-Api-Version
- apikey
- Prefer
- Range
- Range-Unit
- X-Requested-With
exposed_headers:
- Content-Range
- Content-Profile
- X-Total-Count
credentials: true
max_age: 3600
preflight_continue: false
## Analytics routes
- name: analytics-v1
_comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*'
@@ -243,25 +591,41 @@ services:
- name: cors
config:
origins:
- "http://localhost:3000"
- "http://127.0.0.1:3000"
- "https://app.classroomcopilot.ai"
- "https://api.classroomcopilot.ai"
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
- "http://192.168.0.94:50001"
- "http://192.168.0.74"
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- HEAD
headers:
- Accept
- Accept-Profile
- Authorization
- Content-Type
- Content-Profile
- X-Client-Info
- X-Supabase-Api-Version
- apikey
- Mcp-Session-Id
- Prefer
- Range
- Range-Unit
- X-Requested-With
exposed_headers:
- Mcp-Session-Id
- Content-Range
- Content-Profile
- X-Total-Count
credentials: true
max_age: 3600
preflight_continue: false
## Protected Dashboard - catch all remaining routes
#- name: dashboard
-364
View File
@@ -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
View 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
View 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()));
-191
View File
@@ -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
View 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 (MonFri within term bounds).
-- excluded_period_codes: period codes from the template that do NOT apply this day.
-- academic_day_number: sequential count of Academic-type days across the year.
CREATE TABLE IF NOT EXISTS academic_days (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
academic_week_id UUID NOT NULL REFERENCES academic_weeks(id) ON DELETE CASCADE,
academic_term_id UUID NOT NULL REFERENCES academic_terms(id) ON DELETE CASCADE,
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
date DATE NOT NULL,
day_of_week TEXT NOT NULL,
day_type TEXT NOT NULL DEFAULT 'Academic'
CHECK (day_type IN ('Academic', 'Holiday', 'Staff', 'OffTimetable')),
academic_day_number INTEGER, -- null for non-Academic days
excluded_period_codes TEXT[] NOT NULL DEFAULT '{}',
neo4j_node_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (institute_id, date)
);
-- ─── 6. teacher_timetables ───────────────────────────────────────────────────
-- One per teacher per academic year.
CREATE TABLE IF NOT EXISTS teacher_timetables (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
school_timetable_id UUID NOT NULL REFERENCES school_timetables(id) ON DELETE CASCADE,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
neo4j_node_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (profile_id, school_timetable_id)
);
-- ─── 7. teacher_timetable_slots ──────────────────────────────────────────────
-- Weekly recurring slot assignments (day + period → subject class).
CREATE TABLE IF NOT EXISTS teacher_timetable_slots (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
teacher_timetable_id UUID NOT NULL REFERENCES teacher_timetables(id) ON DELETE CASCADE,
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
day_of_week TEXT NOT NULL,
period_code TEXT NOT NULL,
subject_class TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
neo4j_node_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (teacher_timetable_id, day_of_week, period_code)
);
-- ============================================================
-- Indexes
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_school_tt_institute ON school_timetables(institute_id);
CREATE INDEX IF NOT EXISTS idx_academic_years_tt ON academic_years(school_timetable_id);
CREATE INDEX IF NOT EXISTS idx_academic_years_inst ON academic_years(institute_id);
CREATE INDEX IF NOT EXISTS idx_academic_terms_year ON academic_terms(academic_year_id);
CREATE INDEX IF NOT EXISTS idx_academic_terms_inst ON academic_terms(institute_id);
CREATE INDEX IF NOT EXISTS idx_academic_weeks_term ON academic_weeks(academic_term_id);
CREATE INDEX IF NOT EXISTS idx_academic_weeks_inst ON academic_weeks(institute_id);
CREATE INDEX IF NOT EXISTS idx_academic_days_week ON academic_days(academic_week_id);
CREATE INDEX IF NOT EXISTS idx_academic_days_term ON academic_days(academic_term_id);
CREATE INDEX IF NOT EXISTS idx_academic_days_inst_date ON academic_days(institute_id, date);
CREATE INDEX IF NOT EXISTS idx_teacher_tt_profile ON teacher_timetables(profile_id);
CREATE INDEX IF NOT EXISTS idx_teacher_tt_inst ON teacher_timetables(institute_id);
CREATE INDEX IF NOT EXISTS idx_tt_slots_timetable ON teacher_timetable_slots(teacher_timetable_id);
CREATE INDEX IF NOT EXISTS idx_tt_slots_profile ON teacher_timetable_slots(profile_id);
-- ============================================================
-- updated_at triggers (tables that have updated_at)
-- ============================================================
DO $$ DECLARE t TEXT; BEGIN
FOREACH t IN ARRAY ARRAY[
'school_timetables', 'teacher_timetables', 'teacher_timetable_slots'
] LOOP
EXECUTE format(
'DROP TRIGGER IF EXISTS trg_updated_at ON %I;
CREATE TRIGGER trg_updated_at
BEFORE UPDATE ON %I
FOR EACH ROW EXECUTE FUNCTION set_updated_at();',
t, t
);
END LOOP;
END $$;
-- ============================================================
-- Row Level Security
-- ============================================================
ALTER TABLE school_timetables ENABLE ROW LEVEL SECURITY;
ALTER TABLE academic_years ENABLE ROW LEVEL SECURITY;
ALTER TABLE academic_terms ENABLE ROW LEVEL SECURITY;
ALTER TABLE academic_weeks ENABLE ROW LEVEL SECURITY;
ALTER TABLE academic_days ENABLE ROW LEVEL SECURITY;
ALTER TABLE teacher_timetables ENABLE ROW LEVEL SECURITY;
ALTER TABLE teacher_timetable_slots ENABLE ROW LEVEL SECURITY;
-- school_timetables: institute members can read
CREATE POLICY "stt_inst_read" ON school_timetables FOR SELECT
USING (institute_id IN (
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
));
CREATE POLICY "stt_service" ON school_timetables FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- academic_years
CREATE POLICY "ay_inst_read" ON academic_years FOR SELECT
USING (institute_id IN (
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
));
CREATE POLICY "ay_service" ON academic_years FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- academic_terms
CREATE POLICY "at_inst_read" ON academic_terms FOR SELECT
USING (institute_id IN (
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
));
CREATE POLICY "at_service" ON academic_terms FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- academic_weeks
CREATE POLICY "aw_inst_read" ON academic_weeks FOR SELECT
USING (institute_id IN (
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
));
CREATE POLICY "aw_service" ON academic_weeks FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- academic_days
CREATE POLICY "ad_inst_read" ON academic_days FOR SELECT
USING (institute_id IN (
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
));
CREATE POLICY "ad_service" ON academic_days FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- teacher_timetables: own row
CREATE POLICY "tcht_own_read" ON teacher_timetables FOR SELECT
USING (profile_id = auth.uid());
CREATE POLICY "tcht_service" ON teacher_timetables FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- teacher_timetable_slots: own rows
CREATE POLICY "tchts_own_read" ON teacher_timetable_slots FOR SELECT
USING (profile_id = auth.uid());
CREATE POLICY "tchts_service" ON teacher_timetable_slots FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-20
View File
@@ -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
View 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');
-20
View File
@@ -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
-95
View File
@@ -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
View 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.
-84
View File
@@ -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
@@ -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
View 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', '[email protected]', 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', '[email protected]', 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', '[email protected]', 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', '[email protected]', 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', '[email protected]', 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', '[email protected]', 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', '[email protected]', 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', '[email protected]', 'admin', 'platform-admin', 'Platform Admin', 'Platform Admin', NULL, '{"seed":"dev","role":"platform_admin"}'),
('00000000-0000-4000-8000-000000000002', '[email protected]', 'admin', 'school-admin', 'School Admin', 'School Admin', NULL, '{"seed":"dev","role":"school_admin"}'),
('00000000-0000-4000-8000-000000000011', '[email protected]', 'teacher', 'ada-teacher', 'Ada Teacher', 'Ada Teacher', NULL, '{"seed":"dev","department":"Science"}'),
('00000000-0000-4000-8000-000000000012', '[email protected]', 'teacher', 'alan-teacher', 'Alan Teacher', 'Alan Teacher', NULL, '{"seed":"dev","department":"Science"}'),
('00000000-0000-4000-8000-000000000101', '[email protected]', 'student', 'student-one', 'Student One', 'Student One', NULL, '{"seed":"dev","year_group":"9"}'),
('00000000-0000-4000-8000-000000000102', '[email protected]', 'student', 'student-two', 'Student Two', 'Student Two', NULL, '{"seed":"dev","year_group":"9"}'),
('00000000-0000-4000-8000-000000000103', '[email protected]', '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', '[email protected]', 'Platform Admin', 'platform_admin', true, '{"seed":"dev"}')
ON CONFLICT (id) DO UPDATE SET is_super_admin = true, metadata = EXCLUDED.metadata;
INSERT INTO public.institutes (id, name, urn, status, address, website, metadata, geo_coordinates)
VALUES (
'00000000-0000-4000-8000-000000000201',
'Classroom Copilot Dev School',
'DEV0001',
'active',
'{"line1":"1 Fixture Road","town":"Dev Town","postcode":"CC1 1DV","country":"GB"}',
'https://classroomcopilot.dev',
'{"seed":"dev","local_authority":"Fixture LA"}',
'{"lat":51.5007,"lon":-0.1246}'
)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, metadata = EXCLUDED.metadata;
UPDATE public.profiles
SET school_id = '00000000-0000-4000-8000-000000000201'
WHERE id <> '00000000-0000-4000-8000-000000000001';
INSERT INTO public.institute_memberships (profile_id, institute_id, role, metadata)
VALUES
('00000000-0000-4000-8000-000000000002', '00000000-0000-4000-8000-000000000201', 'school_admin', '{"seed":"dev"}'),
('00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', 'teacher', '{"seed":"dev"}'),
('00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', 'teacher', '{"seed":"dev"}'),
('00000000-0000-4000-8000-000000000101', '00000000-0000-4000-8000-000000000201', 'student', '{"seed":"dev"}'),
('00000000-0000-4000-8000-000000000102', '00000000-0000-4000-8000-000000000201', 'student', '{"seed":"dev"}'),
('00000000-0000-4000-8000-000000000103', '00000000-0000-4000-8000-000000000201', 'student', '{"seed":"dev"}')
ON CONFLICT (profile_id, institute_id) DO UPDATE SET role = EXCLUDED.role, metadata = EXCLUDED.metadata;
INSERT INTO public.classes (id, institute_id, name, class_code, subject, key_stage, year_group, academic_year, description, created_by)
VALUES
('00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000201', '9 Science A', '9SCI-A', 'Science', '3', '9', '2026-2027', 'Deterministic dev Year 9 science class', '00000000-0000-4000-8000-000000000002'),
('00000000-0000-4000-8000-000000000302', '00000000-0000-4000-8000-000000000201', '10 Physics B', '10PHY-B', 'Physics', '4', '10', '2026-2027', 'Deterministic dev Year 10 physics class', '00000000-0000-4000-8000-000000000002')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, class_code = EXCLUDED.class_code;
INSERT INTO public.class_teachers (class_id, teacher_id, is_primary, assigned_by)
VALUES
('00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000011', true, '00000000-0000-4000-8000-000000000002'),
('00000000-0000-4000-8000-000000000302', '00000000-0000-4000-8000-000000000012', true, '00000000-0000-4000-8000-000000000002')
ON CONFLICT (class_id, teacher_id) DO UPDATE SET is_primary = EXCLUDED.is_primary;
INSERT INTO public.class_students (class_id, student_id, enrolled_by)
VALUES
('00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000101', '00000000-0000-4000-8000-000000000002'),
('00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000102', '00000000-0000-4000-8000-000000000002'),
('00000000-0000-4000-8000-000000000302', '00000000-0000-4000-8000-000000000103', '00000000-0000-4000-8000-000000000002')
ON CONFLICT (class_id, student_id) DO UPDATE SET status = 'active';
INSERT INTO public.whiteboard_rooms (id, user_id, institute_id, name, context_type, context_id, is_default, storage_path, node_type)
VALUES
('00000000-0000-4000-8000-000000000401', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', 'Ada Teacher Workspace', 'profile', '00000000-0000-4000-8000-000000000011', true, 'cc.users/00000000-0000-4000-8000-000000000011/tldraw/default.json', 'profile_workspace'),
('00000000-0000-4000-8000-000000000402', '00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', 'Alan Teacher Workspace', 'profile', '00000000-0000-4000-8000-000000000012', true, 'cc.users/00000000-0000-4000-8000-000000000012/tldraw/default.json', 'profile_workspace')
ON CONFLICT (id) DO UPDATE SET storage_path = EXCLUDED.storage_path, is_default = EXCLUDED.is_default;
INSERT INTO public.school_timetables (id, institute_id, year_label, start_date, end_date, periods_template)
VALUES (
'00000000-0000-4000-8000-000000000501',
'00000000-0000-4000-8000-000000000201',
'2026-2027',
'2026-09-01',
'2026-09-07',
'[{"code":"P1","name":"Period 1","type":"lesson","start_time":"09:00","end_time":"10:00"},{"code":"P2","name":"Period 2","type":"lesson","start_time":"10:05","end_time":"11:05"},{"code":"BR","name":"Break","type":"break","start_time":"11:05","end_time":"11:25"},{"code":"P3","name":"Period 3","type":"lesson","start_time":"11:25","end_time":"12:25"}]'
)
ON CONFLICT (institute_id, year_label) DO UPDATE SET periods_template = EXCLUDED.periods_template;
INSERT INTO public.academic_years (id, school_timetable_id, institute_id, year_label)
VALUES ('00000000-0000-4000-8000-000000000511', '00000000-0000-4000-8000-000000000501', '00000000-0000-4000-8000-000000000201', '2026-2027')
ON CONFLICT (school_timetable_id, year_label) DO NOTHING;
INSERT INTO public.academic_terms (id, academic_year_id, institute_id, term_name, term_number, start_date, end_date, notes, tags)
VALUES ('00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000511', '00000000-0000-4000-8000-000000000201', 'Autumn fixture week', 1, '2026-09-01', '2026-09-07', 'Single deterministic week for dev smoke tests', '{dev}')
ON CONFLICT (academic_year_id, term_number) DO UPDATE SET start_date = EXCLUDED.start_date, end_date = EXCLUDED.end_date;
INSERT INTO public.academic_weeks (id, academic_term_id, institute_id, week_number, start_date, week_cycle, notes, tags)
VALUES ('00000000-0000-4000-8000-000000000531', '00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000201', 1, '2026-09-01', 'A', 'Deterministic week A', '{dev}')
ON CONFLICT (academic_term_id, week_number) DO UPDATE SET week_cycle = EXCLUDED.week_cycle;
INSERT INTO public.academic_days (id, academic_week_id, academic_term_id, institute_id, date, day_of_week, academic_day_number, notes, tags)
VALUES
('00000000-0000-4000-8000-000000000541', '00000000-0000-4000-8000-000000000531', '00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000201', '2026-09-01', 'Tuesday', 1, 'Fixture day 1', '{dev}'),
('00000000-0000-4000-8000-000000000542', '00000000-0000-4000-8000-000000000531', '00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000201', '2026-09-02', 'Wednesday', 2, 'Fixture day 2', '{dev}'),
('00000000-0000-4000-8000-000000000543', '00000000-0000-4000-8000-000000000531', '00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000201', '2026-09-03', 'Thursday', 3, 'Fixture day 3', '{dev}'),
('00000000-0000-4000-8000-000000000544', '00000000-0000-4000-8000-000000000531', '00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000201', '2026-09-04', 'Friday', 4, 'Fixture day 4', '{dev}')
ON CONFLICT (institute_id, date) DO UPDATE SET academic_day_number = EXCLUDED.academic_day_number;
INSERT INTO public.academic_periods (id, academic_day_id, institute_id, period_code, period_name, period_type, start_time, end_time)
SELECT
('00000000-0000-4000-8000-' || lpad((600 + d.day_no * 10 + p.period_no)::text, 12, '0'))::uuid,
d.id,
'00000000-0000-4000-8000-000000000201'::uuid,
p.period_code,
p.period_name,
p.period_type,
p.start_time::time,
p.end_time::time
FROM (VALUES
('00000000-0000-4000-8000-000000000541'::uuid, 1),
('00000000-0000-4000-8000-000000000542'::uuid, 2),
('00000000-0000-4000-8000-000000000543'::uuid, 3),
('00000000-0000-4000-8000-000000000544'::uuid, 4)
) AS d(id, day_no)
CROSS JOIN (VALUES
(1, 'P1', 'Period 1', 'lesson', '09:00', '10:00'),
(2, 'P2', 'Period 2', 'lesson', '10:05', '11:05'),
(3, 'BR', 'Break', 'break', '11:05', '11:25'),
(4, 'P3', 'Period 3', 'lesson', '11:25', '12:25')
) AS p(period_no, period_code, period_name, period_type, start_time, end_time)
ON CONFLICT (academic_day_id, period_code) DO UPDATE SET period_name = EXCLUDED.period_name;
INSERT INTO public.teacher_timetables (id, profile_id, institute_id, school_timetable_id, start_date, end_date)
VALUES
('00000000-0000-4000-8000-000000000701', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000501', '2026-09-01', '2026-09-07'),
('00000000-0000-4000-8000-000000000702', '00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000501', '2026-09-01', '2026-09-07')
ON CONFLICT (profile_id, school_timetable_id) DO UPDATE SET start_date = EXCLUDED.start_date, end_date = EXCLUDED.end_date;
INSERT INTO public.teacher_timetable_slots (id, teacher_timetable_id, profile_id, institute_id, day_of_week, period_code, subject_class, start_time, end_time, week_cycle, class_id)
VALUES
('00000000-0000-4000-8000-000000000711', '00000000-0000-4000-8000-000000000701', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', 'Tuesday', 'P1', '9 Science A', '09:00', '10:00', 'A', '00000000-0000-4000-8000-000000000301'),
('00000000-0000-4000-8000-000000000712', '00000000-0000-4000-8000-000000000701', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', 'Wednesday', 'P2', '9 Science A', '10:05', '11:05', 'A', '00000000-0000-4000-8000-000000000301'),
('00000000-0000-4000-8000-000000000713', '00000000-0000-4000-8000-000000000702', '00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', 'Thursday', 'P1', '10 Physics B', '09:00', '10:00', 'A', '00000000-0000-4000-8000-000000000302')
ON CONFLICT (teacher_timetable_id, week_cycle, day_of_week, period_code) DO UPDATE SET class_id = EXCLUDED.class_id, subject_class = EXCLUDED.subject_class;
INSERT INTO public.taught_lessons (id, academic_period_id, teacher_timetable_slot_id, class_id, teacher_id, institute_id, date, period_code, week_cycle, day_of_week, lesson_plan, whiteboard_room_id, status, notes, tags)
VALUES
('00000000-0000-4000-8000-000000000801', '00000000-0000-4000-8000-000000000611', '00000000-0000-4000-8000-000000000711', '00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', '2026-09-01', 'P1', 'A', 'Tuesday', '{"title":"Forces baseline","objectives":["Describe balanced and unbalanced forces"]}', '00000000-0000-4000-8000-000000000401', 'planned', 'Dev fixture taught lesson', '{dev,science}'),
('00000000-0000-4000-8000-000000000802', '00000000-0000-4000-8000-000000000622', '00000000-0000-4000-8000-000000000712', '00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', '2026-09-02', 'P2', 'A', 'Wednesday', '{"title":"Particle model recap","objectives":["Compare solids liquids and gases"]}', '00000000-0000-4000-8000-000000000401', 'planned', 'Dev fixture taught lesson', '{dev,science}'),
('00000000-0000-4000-8000-000000000803', '00000000-0000-4000-8000-000000000631', '00000000-0000-4000-8000-000000000713', '00000000-0000-4000-8000-000000000302', '00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', '2026-09-03', 'P1', 'A', 'Thursday', '{"title":"Energy stores","objectives":["Identify energy stores in examples"]}', '00000000-0000-4000-8000-000000000402', 'planned', 'Dev fixture taught lesson', '{dev,physics}')
ON CONFLICT (academic_period_id, teacher_id) DO UPDATE SET lesson_plan = EXCLUDED.lesson_plan, status = EXCLUDED.status;
INSERT INTO public.curriculum_topics (id, title, subject, key_stage, year_group, topic_type, total_lessons, department)
VALUES
('DEV-SCI-FORCES', 'Forces baseline', 'Science', '3', '9', 'Standard', 1, 'Science'),
('DEV-PHY-ENERGY', 'Energy stores', 'Physics', '4', '10', 'Standard', 1, 'Science')
ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title;
INSERT INTO public.planned_lessons (id, created_by, institute_id, class_id, whiteboard_room_id, topic_code, title, subject, year_group, estimated_duration_minutes, objectives, activities, status, tags)
VALUES
('00000000-0000-4000-8000-000000000901', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000401', 'DEV-SCI-FORCES', 'Forces baseline planned lesson', 'Science', '9', 60, '["Describe balanced and unbalanced forces"]', '[{"type":"starter","title":"Force diagrams"}]', 'ready', '{dev,science}'),
('00000000-0000-4000-8000-000000000902', '00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000302', '00000000-0000-4000-8000-000000000402', 'DEV-PHY-ENERGY', 'Energy stores planned lesson', 'Physics', '10', 60, '["Identify energy stores"]', '[{"type":"main","title":"Energy transfer circus"}]', 'ready', '{dev,physics}')
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, objectives = EXCLUDED.objectives;
INSERT INTO public.lesson_deliveries (id, planned_lesson_id, taught_lesson_id, delivered_by, class_id, institute_id, whiteboard_room_id, started_at, ended_at, notes)
VALUES
('00000000-0000-4000-8000-000000000911', '00000000-0000-4000-8000-000000000901', '00000000-0000-4000-8000-000000000801', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000401', '2026-09-01 09:00:00+00', '2026-09-01 10:00:00+00', 'Delivered lesson fixture')
ON CONFLICT (id) DO UPDATE SET taught_lesson_id = EXCLUDED.taught_lesson_id;
-- Dev storage buckets expected by app/API. Object rows are intentionally omitted;
-- TLDraw paths above point at where empty/default snapshots should be written.
INSERT INTO storage.buckets (id, name, public)
VALUES
('cc.users', 'cc.users', false),
('cc.public.snapshots', 'cc.public.snapshots', false),
('cc.examboards', 'cc.examboards', true)
ON CONFLICT (id) DO UPDATE SET public = EXCLUDED.public;
-- Lightweight verification breadcrumbs for SQL-level smoke checks.
DO $$
DECLARE
profile_count integer;
institute_count integer;
class_count integer;
taught_count integer;
BEGIN
SELECT count(*) INTO profile_count FROM public.profiles WHERE metadata->>'seed' = 'dev';
SELECT count(*) INTO institute_count FROM public.institutes WHERE metadata->>'seed' = 'dev';
SELECT count(*) INTO class_count FROM public.classes WHERE academic_year = '2026-2027';
SELECT count(*) INTO taught_count FROM public.taught_lessons WHERE tags @> ARRAY['dev'];
IF profile_count <> 7 OR institute_count <> 1 OR class_count <> 2 OR taught_count <> 3 THEN
RAISE EXCEPTION 'Dev seed verification failed: profiles=%, institutes=%, classes=%, taught_lessons=%', profile_count, institute_count, class_count, taught_count;
END IF;
END $$;
-79
View File
@@ -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)
$$;
-73
View File
@@ -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()
));
-48
View File
@@ -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"
-- }