Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89db695555 | ||
|
|
feceaf64b6 | ||
|
|
10314ddd62 | ||
|
|
fcab68f57a | ||
|
|
eab7c01f46 | ||
|
|
b65e3f2d38 | ||
|
|
84d8303cdb | ||
|
|
bc674ea696 | ||
|
|
b758b40d85 | ||
|
|
e0d2c5c619 | ||
|
|
5573b8fede |
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
-- 71-class-management.sql
|
||||
-- Foundational: capture the (previously untracked) class-management schema and harden its RLS.
|
||||
--
|
||||
-- Background: `classes`, `class_teachers`, `class_students`, `enrollment_requests` existed only on
|
||||
-- live dev (.94), created out-of-band, with NO tracked DDL. Their schema/FKs/uniques are sound,
|
||||
-- but RLS exposed `class_students` / `class_teachers` to service_role ONLY — so any API path that
|
||||
-- calls Supabase AS THE USER (the correct, RLS-enforced pattern) reads ZERO rows. This migration:
|
||||
-- 1. captures the real schema (idempotent; no-op on environments that already have it),
|
||||
-- 2. adds SECURITY DEFINER membership helpers (avoid RLS recursion in policies),
|
||||
-- 3. adds as-user RLS policies so teachers/admins/students can read rosters under RLS,
|
||||
-- while keeping the existing service_role policies intact.
|
||||
-- Verified against live .94 schema 2026-06-06 (all FKs/uniques/checks already present there).
|
||||
|
||||
--==========================================================================================
|
||||
-- 1. Tables (idempotent capture for fresh environments; skipped where they already exist)
|
||||
--==========================================================================================
|
||||
|
||||
create table if not exists public.classes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
institute_id uuid not null references public.institutes(id) on delete cascade,
|
||||
name varchar not null,
|
||||
class_code text,
|
||||
subject varchar,
|
||||
key_stage text,
|
||||
year_group varchar,
|
||||
academic_year varchar,
|
||||
description text,
|
||||
type varchar not null default 'standard',
|
||||
is_active boolean not null default true,
|
||||
created_by uuid not null references public.profiles(id),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.class_teachers (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
class_id uuid not null references public.classes(id) on delete cascade,
|
||||
teacher_id uuid not null references public.profiles(id) on delete cascade,
|
||||
is_primary boolean not null default false,
|
||||
can_edit boolean not null default true,
|
||||
assigned_at timestamptz not null default now(),
|
||||
assigned_by uuid references public.profiles(id),
|
||||
constraint class_teachers_class_id_teacher_id_key unique (class_id, teacher_id)
|
||||
);
|
||||
|
||||
create table if not exists public.class_students (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
class_id uuid not null references public.classes(id) on delete cascade,
|
||||
student_id uuid not null references public.profiles(id) on delete cascade,
|
||||
status varchar not null default 'active'
|
||||
check (status::text = any (array['active','inactive','pending'])),
|
||||
enrolled_at timestamptz not null default now(),
|
||||
enrolled_by uuid references public.profiles(id),
|
||||
constraint class_students_class_id_student_id_key unique (class_id, student_id)
|
||||
);
|
||||
|
||||
create table if not exists public.enrollment_requests (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
class_id uuid not null references public.classes(id) on delete cascade,
|
||||
student_id uuid not null references public.profiles(id) on delete cascade,
|
||||
status varchar not null default 'pending'
|
||||
check (status::text = any (array['pending','approved','rejected'])),
|
||||
request_message text,
|
||||
requested_at timestamptz not null default now(),
|
||||
responded_at timestamptz,
|
||||
responded_by uuid references public.profiles(id),
|
||||
response_message text
|
||||
);
|
||||
|
||||
create index if not exists idx_classes_institute on public.classes(institute_id);
|
||||
create index if not exists idx_classes_created_by on public.classes(created_by);
|
||||
create index if not exists idx_classes_class_code on public.classes(class_code);
|
||||
create index if not exists idx_class_teachers_class on public.class_teachers(class_id);
|
||||
create index if not exists idx_class_students_class on public.class_students(class_id);
|
||||
|
||||
--==========================================================================================
|
||||
-- 2. SECURITY DEFINER membership helpers
|
||||
-- Run as owner (bypass RLS on the inner tables) → no policy recursion when referenced below.
|
||||
--==========================================================================================
|
||||
|
||||
create or replace function public.user_institute_ids()
|
||||
returns setof uuid language sql stable security definer set search_path = public as $$
|
||||
select institute_id from public.institute_memberships where profile_id = auth.uid()
|
||||
$$;
|
||||
|
||||
create or replace function public.is_class_teacher(p_class uuid)
|
||||
returns boolean language sql stable security definer set search_path = public as $$
|
||||
select exists (select 1 from public.class_teachers
|
||||
where class_id = p_class and teacher_id = auth.uid())
|
||||
$$;
|
||||
|
||||
create or replace function public.is_class_admin(p_class uuid)
|
||||
returns boolean language sql stable security definer set search_path = public as $$
|
||||
select exists (
|
||||
select 1 from public.classes c
|
||||
join public.institute_memberships m on m.institute_id = c.institute_id
|
||||
where c.id = p_class and m.profile_id = auth.uid()
|
||||
and m.role in ('school_admin','department_head'))
|
||||
$$;
|
||||
|
||||
create or replace function public.is_institute_member_of_class(p_class uuid)
|
||||
returns boolean language sql stable security definer set search_path = public as $$
|
||||
select exists (
|
||||
select 1 from public.classes c
|
||||
join public.institute_memberships m on m.institute_id = c.institute_id
|
||||
where c.id = p_class and m.profile_id = auth.uid())
|
||||
$$;
|
||||
|
||||
--==========================================================================================
|
||||
-- 3. RLS — enable + (re)declare every policy so this file is the source of truth.
|
||||
-- Existing service_role / institute_read / er_own policies are preserved verbatim;
|
||||
-- the cs_read/cs_write/ct_read/ct_write policies are the NEW as-user grants.
|
||||
--==========================================================================================
|
||||
|
||||
alter table public.classes enable row level security;
|
||||
alter table public.class_teachers enable row level security;
|
||||
alter table public.class_students enable row level security;
|
||||
alter table public.enrollment_requests enable row level security;
|
||||
|
||||
-- classes ---------------------------------------------------------------------------------
|
||||
drop policy if exists classes_service_role on public.classes;
|
||||
create policy classes_service_role on public.classes
|
||||
using (auth.role() = 'service_role');
|
||||
|
||||
drop policy if exists classes_institute_read on public.classes;
|
||||
create policy classes_institute_read on public.classes for select to authenticated
|
||||
using (institute_id in (select public.user_institute_ids()));
|
||||
|
||||
-- NEW: teachers/admins of a class can update it; admins can insert/delete within their institute
|
||||
drop policy if exists classes_admin_write on public.classes;
|
||||
create policy classes_admin_write on public.classes for all to authenticated
|
||||
using (institute_id in (select public.user_institute_ids())
|
||||
and (public.is_class_admin(id) or public.is_class_teacher(id)))
|
||||
with check (institute_id in (select public.user_institute_ids()));
|
||||
|
||||
-- class_teachers --------------------------------------------------------------------------
|
||||
drop policy if exists ct_service_role on public.class_teachers;
|
||||
create policy ct_service_role on public.class_teachers
|
||||
using (auth.role() = 'service_role');
|
||||
|
||||
-- NEW: institute members can see who teaches a class; the teacher can see their own rows
|
||||
drop policy if exists ct_read on public.class_teachers;
|
||||
create policy ct_read on public.class_teachers for select to authenticated
|
||||
using (teacher_id = auth.uid() or public.is_institute_member_of_class(class_id));
|
||||
|
||||
-- NEW: only school admins assign/unassign teachers
|
||||
drop policy if exists ct_write on public.class_teachers;
|
||||
create policy ct_write on public.class_teachers for all to authenticated
|
||||
using (public.is_class_admin(class_id))
|
||||
with check (public.is_class_admin(class_id));
|
||||
|
||||
-- class_students --------------------------------------------------------------------------
|
||||
drop policy if exists cs_service_role on public.class_students;
|
||||
create policy cs_service_role on public.class_students
|
||||
using (auth.role() = 'service_role');
|
||||
|
||||
-- NEW: a student sees their own enrolment; teachers/admins of the class see the roster
|
||||
drop policy if exists cs_read on public.class_students;
|
||||
create policy cs_read on public.class_students for select to authenticated
|
||||
using (student_id = auth.uid()
|
||||
or public.is_class_teacher(class_id)
|
||||
or public.is_class_admin(class_id));
|
||||
|
||||
-- NEW: teachers (can_edit) and admins of the class manage enrolments
|
||||
drop policy if exists cs_write on public.class_students;
|
||||
create policy cs_write on public.class_students for all to authenticated
|
||||
using (public.is_class_teacher(class_id) or public.is_class_admin(class_id))
|
||||
with check (public.is_class_teacher(class_id) or public.is_class_admin(class_id));
|
||||
|
||||
-- enrollment_requests ---------------------------------------------------------------------
|
||||
drop policy if exists er_service_role on public.enrollment_requests;
|
||||
create policy er_service_role on public.enrollment_requests
|
||||
using (auth.role() = 'service_role');
|
||||
|
||||
drop policy if exists er_own on public.enrollment_requests;
|
||||
create policy er_own on public.enrollment_requests for all to authenticated
|
||||
using (student_id = auth.uid())
|
||||
with check (student_id = auth.uid());
|
||||
|
||||
-- NEW: teachers/admins of the class can read + respond to requests for their class
|
||||
drop policy if exists er_class_staff on public.enrollment_requests;
|
||||
create policy er_class_staff on public.enrollment_requests for all to authenticated
|
||||
using (public.is_class_teacher(class_id) or public.is_class_admin(class_id))
|
||||
with check (public.is_class_teacher(class_id) or public.is_class_admin(class_id));
|
||||
@@ -0,0 +1,238 @@
|
||||
-- 72-exam-marker.sql
|
||||
-- Exam-marker operational tables (Supabase = source of truth for geometry/marks/submissions).
|
||||
-- Neo4j cc.public.exams holds the knowledge graph; joined by shared UUIDs (see spec §2).
|
||||
--
|
||||
-- Authorization is owned by this layer: the exam API calls Supabase AS THE USER, so these RLS
|
||||
-- policies are enforced (service_role policies cover the Neo4j-projection / seed paths only).
|
||||
-- Depends on: 71-class-management.sql (marking_batches.class_id → classes; user_institute_ids()).
|
||||
|
||||
--==========================================================================================
|
||||
-- 1. Tables
|
||||
--==========================================================================================
|
||||
|
||||
create table if not exists public.exam_templates (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
exam_id uuid references public.eb_exams(id) on delete set null, -- null for ad-hoc upload
|
||||
exam_code text, -- denormalised → Neo4j join
|
||||
institute_id uuid not null references public.institutes(id) on delete cascade,
|
||||
teacher_id uuid not null references public.profiles(id) on delete cascade,
|
||||
title text not null,
|
||||
subject text,
|
||||
source_file_id uuid references public.files(id) on delete set null, -- uploaded PDF (R2.2)
|
||||
page_count int,
|
||||
status text not null default 'draft' check (status in ('draft','ready','archived')),
|
||||
created_at timestamptz not null default timezone('utc', now()),
|
||||
updated_at timestamptz not null default timezone('utc', now())
|
||||
);
|
||||
|
||||
create table if not exists public.exam_questions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
template_id uuid not null references public.exam_templates(id) on delete cascade,
|
||||
parent_id uuid references public.exam_questions(id) on delete cascade,
|
||||
label text not null,
|
||||
"order" int not null default 0,
|
||||
max_marks numeric not null default 0,
|
||||
answer_type text check (answer_type in ('written','mcq','short','diagram')),
|
||||
mcq_options jsonb,
|
||||
mark_scheme jsonb not null default '{}'::jsonb, -- MarkScheme union from exam-marker types.ts
|
||||
is_container boolean not null default false, -- true → Neo4j Question, false → Part
|
||||
spec_ref text, -- manual spec-point tag → ASSESSES (R3.5.3)
|
||||
created_at timestamptz not null default timezone('utc', now()),
|
||||
updated_at timestamptz not null default timezone('utc', now())
|
||||
);
|
||||
|
||||
create table if not exists public.exam_response_areas (
|
||||
id uuid primary key default gen_random_uuid(), -- == Neo4j Region.uuid_string
|
||||
question_id uuid not null references public.exam_questions(id) on delete cascade,
|
||||
template_id uuid not null references public.exam_templates(id) on delete cascade, -- RLS denorm
|
||||
page int not null,
|
||||
bounds jsonb not null, -- {x,y,w,h}
|
||||
kind text not null check (kind in ('response','context')),
|
||||
response_form text check (response_form in
|
||||
('lines','answer-box','working','diagram','tick-boxes','table','blanks')),
|
||||
source text not null default 'manual' check (source in ('manual','ai')),
|
||||
confirmed boolean not null default true,
|
||||
confidence numeric,
|
||||
created_at timestamptz not null default timezone('utc', now())
|
||||
);
|
||||
|
||||
create table if not exists public.exam_boundaries (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
template_id uuid not null references public.exam_templates(id) on delete cascade,
|
||||
question_id uuid references public.exam_questions(id) on delete set null,
|
||||
label text,
|
||||
page_index int not null,
|
||||
y numeric not null,
|
||||
bounds jsonb,
|
||||
source text not null default 'manual' check (source in ('manual','ai')),
|
||||
confirmed boolean not null default true,
|
||||
created_at timestamptz not null default timezone('utc', now())
|
||||
);
|
||||
|
||||
create table if not exists public.marking_batches (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
template_id uuid not null references public.exam_templates(id) on delete cascade,
|
||||
class_id uuid references public.classes(id) on delete set null, -- roster via class_students
|
||||
institute_id uuid not null references public.institutes(id) on delete cascade,
|
||||
teacher_id uuid not null references public.profiles(id) on delete cascade, -- batch owner (R2.4)
|
||||
title text,
|
||||
status text not null default 'open' check (status in ('open','marking','complete','archived')),
|
||||
created_at timestamptz not null default timezone('utc', now()),
|
||||
updated_at timestamptz not null default timezone('utc', now())
|
||||
);
|
||||
|
||||
create table if not exists public.student_submissions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
batch_id uuid not null references public.marking_batches(id) on delete cascade,
|
||||
student_id uuid references public.profiles(id) on delete set null, -- null until matched
|
||||
student_name text,
|
||||
scan_file_id uuid references public.files(id) on delete set null,
|
||||
scan_url text,
|
||||
qr_code text,
|
||||
matching_method text check (matching_method in ('ordered','ocr_name','qr_code','manual')),
|
||||
match_confidence numeric,
|
||||
page_start int,
|
||||
page_count int,
|
||||
status text not null default 'unmatched'
|
||||
check (status in ('unmatched','matched','marking','complete','absent')),
|
||||
annotation_snapshot jsonb,
|
||||
created_at timestamptz not null default timezone('utc', now()),
|
||||
updated_at timestamptz not null default timezone('utc', now())
|
||||
);
|
||||
|
||||
create table if not exists public.mark_entries (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
submission_id uuid not null references public.student_submissions(id) on delete cascade,
|
||||
question_id uuid not null references public.exam_questions(id) on delete cascade,
|
||||
batch_id uuid not null references public.marking_batches(id) on delete cascade, -- RLS denorm
|
||||
awarded_marks numeric not null default 0,
|
||||
mark_scheme_detail jsonb not null default '{}'::jsonb,
|
||||
annotation_shape_ids jsonb not null default '[]'::jsonb,
|
||||
comment text,
|
||||
marked_by text not null default 'teacher' check (marked_by in ('teacher','ai')),
|
||||
ai_confidence numeric,
|
||||
confirmed boolean not null default true,
|
||||
marked_at timestamptz not null default timezone('utc', now())
|
||||
);
|
||||
|
||||
create index if not exists idx_exam_templates_institute on public.exam_templates(institute_id);
|
||||
create index if not exists idx_exam_templates_teacher on public.exam_templates(teacher_id);
|
||||
create index if not exists idx_exam_questions_template on public.exam_questions(template_id);
|
||||
create index if not exists idx_exam_questions_parent on public.exam_questions(parent_id);
|
||||
create index if not exists idx_exam_regions_question on public.exam_response_areas(question_id);
|
||||
create index if not exists idx_exam_regions_template on public.exam_response_areas(template_id);
|
||||
create index if not exists idx_exam_boundaries_template on public.exam_boundaries(template_id);
|
||||
create index if not exists idx_batches_template on public.marking_batches(template_id);
|
||||
create index if not exists idx_batches_institute on public.marking_batches(institute_id);
|
||||
create index if not exists idx_submissions_batch on public.student_submissions(batch_id);
|
||||
create index if not exists idx_marks_submission on public.mark_entries(submission_id);
|
||||
create index if not exists idx_marks_batch on public.mark_entries(batch_id);
|
||||
|
||||
--==========================================================================================
|
||||
-- 2. updated_at triggers (reuse public.handle_updated_at from 62-functions-triggers.sql)
|
||||
--==========================================================================================
|
||||
|
||||
drop trigger if exists handle_exam_templates_updated_at on public.exam_templates;
|
||||
create trigger handle_exam_templates_updated_at before update on public.exam_templates
|
||||
for each row execute function public.handle_updated_at();
|
||||
|
||||
drop trigger if exists handle_exam_questions_updated_at on public.exam_questions;
|
||||
create trigger handle_exam_questions_updated_at before update on public.exam_questions
|
||||
for each row execute function public.handle_updated_at();
|
||||
|
||||
drop trigger if exists handle_marking_batches_updated_at on public.marking_batches;
|
||||
create trigger handle_marking_batches_updated_at before update on public.marking_batches
|
||||
for each row execute function public.handle_updated_at();
|
||||
|
||||
drop trigger if exists handle_student_submissions_updated_at on public.student_submissions;
|
||||
create trigger handle_student_submissions_updated_at before update on public.student_submissions
|
||||
for each row execute function public.handle_updated_at();
|
||||
|
||||
--==========================================================================================
|
||||
-- 3. RLS — every table: a service_role passthrough (Neo4j projection / seeds) + as-user policies
|
||||
--==========================================================================================
|
||||
|
||||
alter table public.exam_templates enable row level security;
|
||||
alter table public.exam_questions enable row level security;
|
||||
alter table public.exam_response_areas enable row level security;
|
||||
alter table public.exam_boundaries enable row level security;
|
||||
alter table public.marking_batches enable row level security;
|
||||
alter table public.student_submissions enable row level security;
|
||||
alter table public.mark_entries enable row level security;
|
||||
|
||||
-- exam_templates -------------------------------------------------------------------------
|
||||
drop policy if exists exam_templates_service on public.exam_templates;
|
||||
create policy exam_templates_service on public.exam_templates using (auth.role() = 'service_role');
|
||||
drop policy if exists exam_templates_read on public.exam_templates;
|
||||
create policy exam_templates_read on public.exam_templates for select to authenticated
|
||||
using (institute_id in (select public.user_institute_ids()));
|
||||
drop policy if exists exam_templates_write on public.exam_templates;
|
||||
create policy exam_templates_write on public.exam_templates for all to authenticated
|
||||
using (teacher_id = auth.uid() and institute_id in (select public.user_institute_ids()))
|
||||
with check (teacher_id = auth.uid() and institute_id in (select public.user_institute_ids()));
|
||||
|
||||
-- exam_questions / exam_response_areas / exam_boundaries: cascade authz from owning template
|
||||
drop policy if exists exam_questions_service on public.exam_questions;
|
||||
create policy exam_questions_service on public.exam_questions using (auth.role() = 'service_role');
|
||||
drop policy if exists exam_questions_all on public.exam_questions;
|
||||
create policy exam_questions_all on public.exam_questions for all to authenticated
|
||||
using (exists (select 1 from public.exam_templates t
|
||||
where t.id = exam_questions.template_id
|
||||
and t.institute_id in (select public.user_institute_ids())))
|
||||
with check (exists (select 1 from public.exam_templates t
|
||||
where t.id = exam_questions.template_id and t.teacher_id = auth.uid()));
|
||||
|
||||
drop policy if exists exam_regions_service on public.exam_response_areas;
|
||||
create policy exam_regions_service on public.exam_response_areas using (auth.role() = 'service_role');
|
||||
drop policy if exists exam_regions_all on public.exam_response_areas;
|
||||
create policy exam_regions_all on public.exam_response_areas for all to authenticated
|
||||
using (exists (select 1 from public.exam_templates t
|
||||
where t.id = exam_response_areas.template_id
|
||||
and t.institute_id in (select public.user_institute_ids())))
|
||||
with check (exists (select 1 from public.exam_templates t
|
||||
where t.id = exam_response_areas.template_id and t.teacher_id = auth.uid()));
|
||||
|
||||
drop policy if exists exam_boundaries_service on public.exam_boundaries;
|
||||
create policy exam_boundaries_service on public.exam_boundaries using (auth.role() = 'service_role');
|
||||
drop policy if exists exam_boundaries_all on public.exam_boundaries;
|
||||
create policy exam_boundaries_all on public.exam_boundaries for all to authenticated
|
||||
using (exists (select 1 from public.exam_templates t
|
||||
where t.id = exam_boundaries.template_id
|
||||
and t.institute_id in (select public.user_institute_ids())))
|
||||
with check (exists (select 1 from public.exam_templates t
|
||||
where t.id = exam_boundaries.template_id and t.teacher_id = auth.uid()));
|
||||
|
||||
-- marking_batches: read = same institute (colleagues), write = owning teacher (R2.4)
|
||||
drop policy if exists marking_batches_service on public.marking_batches;
|
||||
create policy marking_batches_service on public.marking_batches using (auth.role() = 'service_role');
|
||||
drop policy if exists marking_batches_read on public.marking_batches;
|
||||
create policy marking_batches_read on public.marking_batches for select to authenticated
|
||||
using (institute_id in (select public.user_institute_ids()));
|
||||
drop policy if exists marking_batches_write on public.marking_batches;
|
||||
create policy marking_batches_write on public.marking_batches for all to authenticated
|
||||
using (teacher_id = auth.uid() and institute_id in (select public.user_institute_ids()))
|
||||
with check (teacher_id = auth.uid() and institute_id in (select public.user_institute_ids()));
|
||||
|
||||
-- student_submissions: authz cascades from batch ownership
|
||||
drop policy if exists submissions_service on public.student_submissions;
|
||||
create policy submissions_service on public.student_submissions using (auth.role() = 'service_role');
|
||||
drop policy if exists submissions_all on public.student_submissions;
|
||||
create policy submissions_all on public.student_submissions for all to authenticated
|
||||
using (exists (select 1 from public.marking_batches b
|
||||
where b.id = student_submissions.batch_id and b.teacher_id = auth.uid()))
|
||||
with check (exists (select 1 from public.marking_batches b
|
||||
where b.id = student_submissions.batch_id and b.teacher_id = auth.uid()));
|
||||
|
||||
-- mark_entries: teacher (batch owner) full; student may read their own marks (R1.5, UI deferred)
|
||||
drop policy if exists marks_service on public.mark_entries;
|
||||
create policy marks_service on public.mark_entries using (auth.role() = 'service_role');
|
||||
drop policy if exists marks_teacher_all on public.mark_entries;
|
||||
create policy marks_teacher_all on public.mark_entries for all to authenticated
|
||||
using (exists (select 1 from public.marking_batches b
|
||||
where b.id = mark_entries.batch_id and b.teacher_id = auth.uid()))
|
||||
with check (exists (select 1 from public.marking_batches b
|
||||
where b.id = mark_entries.batch_id and b.teacher_id = auth.uid()));
|
||||
drop policy if exists marks_student_read on public.mark_entries;
|
||||
create policy marks_student_read on public.mark_entries for select to authenticated
|
||||
using (exists (select 1 from public.student_submissions s
|
||||
where s.id = mark_entries.submission_id and s.student_id = auth.uid()));
|
||||
@@ -0,0 +1,42 @@
|
||||
-- 73-exam-marker-regions.sql
|
||||
-- Extends the exam-marker physical model (72-exam-marker.sql) for the locked S4-9 shape taxonomy
|
||||
-- (user discussion 2026-06-06; see ~/cc/ideas/2026-06-06-s4-9-design-answers-from-transcript.md).
|
||||
--
|
||||
-- Taxonomy: Boundary carves main Questions; a teacher draws a box around each Part; inside a Part
|
||||
-- live bounded regions of several KINDS. "Band"/"span" are retired. Idempotent (IF NOT EXISTS /
|
||||
-- drop-and-re-add the named CHECK). Additive on top of 72; safe to re-run.
|
||||
|
||||
--==========================================================================================
|
||||
-- 1. exam_questions: geometry for the drawn Part box (and optional main-question region)
|
||||
--==========================================================================================
|
||||
-- Geometry previously lived only on response areas. A Part is now a drawn box, so the question
|
||||
-- row carries its own bounds + page. Nullable: main questions are derived between their boundaries.
|
||||
alter table public.exam_questions add column if not exists bounds jsonb; -- {x,y,w,h}
|
||||
alter table public.exam_questions add column if not exists page int;
|
||||
|
||||
comment on column public.exam_questions.bounds is 'Drawn box for a Part (leaf); null for a derived main question';
|
||||
comment on column public.exam_questions.page is 'Page index the Part box sits on; null for derived main questions';
|
||||
|
||||
--==========================================================================================
|
||||
-- 2. exam_response_areas: more region kinds + context differentiation
|
||||
--==========================================================================================
|
||||
-- v1 keeps one generic Context but plans subject-specific differentiation later
|
||||
-- (graph, chart, data_table, diagram, code_block, passage, …). Nullable now.
|
||||
alter table public.exam_response_areas add column if not exists context_type text;
|
||||
|
||||
comment on column public.exam_response_areas.context_type is
|
||||
'Optional Context differentiation (v1 generic); future: graph|chart|data_table|diagram|code_block|passage';
|
||||
|
||||
-- Extend the kind enum. Region kinds now:
|
||||
-- response - where the student writes (uses response_form)
|
||||
-- context - stimulus the question/part draws on (uses context_type)
|
||||
-- question_number - bounds the printed label "01" / "2.1" (physical metadata for OCR/AI)
|
||||
-- mark_area - bounds the printed marks "[2]" / "Total for Question X is N marks"
|
||||
-- reference - formulae/data sheets, appendices the student uses (kept, NOT ignored)
|
||||
-- furniture - margins, page numbers, blank space, decoration (explicitly excluded)
|
||||
alter table public.exam_response_areas drop constraint if exists exam_response_areas_kind_check;
|
||||
alter table public.exam_response_areas add constraint exam_response_areas_kind_check
|
||||
check (kind in ('response','context','question_number','mark_area','reference','furniture'));
|
||||
|
||||
comment on column public.exam_response_areas.kind is
|
||||
'response|context|question_number|mark_area|reference|furniture (see 73-exam-marker-regions.sql)';
|
||||
Reference in New Issue
Block a user