Author SHA1 Message Date
kcarandClaude Opus 4.8 89db695555 feat(db): exam-marker region kinds + part geometry (73)
supabase-ci / validate (push) Has been cancelled
Extends 72 for the locked S4-9 shape taxonomy (no Band/span):
- exam_questions: add bounds jsonb + page int (the drawn Part box; null for
  derived main questions).
- exam_response_areas: add context_type (v1 generic, future STEM differentiation);
  extend kind CHECK to response|context|question_number|mark_area|reference|furniture.
Additive + idempotent. Applied to dev .94 and verified (columns present; CHECK
def lists all 6 kinds). NOT applied to prod .156.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 21:10:34 +00:00
kcar feceaf64b6 merge: exam-marker Supabase foundation (class-mgmt schema+RLS, exam tables)
supabase-ci / validate (push) Has been cancelled
Brings 71-class-management.sql (tracks the previously-untracked class schema + as-user
RLS helpers) and 72-exam-marker.sql (7 exam tables + RLS). Both applied + verified on
dev .94.
2026-06-06 17:01:41 +00:00
kcarandClaude Opus 4.8 10314ddd62 feat(db): exam-marker operational tables + RLS (72-exam-marker.sql)
Adds the 7 Supabase tables (exam_templates, exam_questions, exam_response_areas,
exam_boundaries, marking_batches, student_submissions, mark_entries) with FKs,
indexes, updated_at triggers (reusing handle_updated_at), and inline RLS.

Authorization owned by this layer (exam API calls as-user): per-table service_role
passthrough + as-user policies scoped via user_institute_ids() (from 71); marks
readable by the owning teacher's batch and by the student themselves (UI deferred).
marking_batches.class_id FKs to public.classes (71).

Applied + verified on dev .94: 7 tables, RLS on, class_id FK valid, teacher can
insert+read a template under RLS. Stacked on feat/class-management-foundation.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 16:11:52 +00:00
kcarandClaude Opus 4.8 fcab68f57a feat(db): track class-management schema + add as-user RLS
The classes/class_teachers/class_students/enrollment_requests tables existed
only on live dev (.94) with no tracked DDL, and RLS exposed class_students /
class_teachers to service_role ONLY — so any API path calling Supabase as the
user read zero rows.

- 71-class-management.sql captures the real schema (idempotent), adds SECURITY
  DEFINER membership helpers, and adds as-user RLS policies (cs_read/cs_write,
  ct_read/ct_write, classes_admin_write, er_class_staff) while preserving the
  existing service_role / institute_read / er_own policies.

Applied + verified on dev .94: class teacher sees roster (1), unrelated teacher
denied (0), service_role unaffected (full). FKs/uniques/checks already present
on .94 (no constraint changes needed).

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