geocrop-platform./apps/nextgen/supabase_migration_v2.sql

408 lines
20 KiB
SQL

-- =====================================================
-- Supabase migration v2: Knex SQLite -> PostgreSQL
-- Knex and the authoritative cloud schema both use UUID tenant identifiers.
-- Knex users permits dining_staff; the current cloud users role CHECK does not.
-- Existing CREATE TABLE IF NOT EXISTS definitions are not rewritten because that
-- would not alter existing cloud columns; only Knex tables missing from cloud are created.
-- =====================================================
-- =====================================================
-- 0. PREREQUISITES
-- =====================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- =====================================================
-- 1. TENANTS (root, no RLS policy needs to be added yet)
-- =====================================================
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
school_name TEXT NOT NULL,
logo_url TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
-- =====================================================
-- 2. TABLES THAT ARE IN Knex BUT NOT YET IN SUPABASE
-- (CREATE TABLE IF NOT EXISTS — safe to re-run)
-- =====================================================
CREATE TABLE IF NOT EXISTS tenant_licenses (
id TEXT PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
license_key TEXT UNIQUE NOT NULL,
term_name TEXT NOT NULL,
academic_year TEXT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
grace_period_days INTEGER DEFAULT 7,
max_students INTEGER DEFAULT 1000,
max_staff INTEGER DEFAULT 200,
status TEXT DEFAULT 'active',
override_reason TEXT,
issued_by TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS school_settings (
key TEXT PRIMARY KEY,
uid TEXT UNIQUE,
value TEXT,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_synced_at TIMESTAMP WITH TIME ZONE,
sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict')),
is_deleted INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS homework (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid TEXT UNIQUE,
subject_id INTEGER NOT NULL REFERENCES subjects(id),
class_id INTEGER REFERENCES classes(id),
title TEXT NOT NULL,
description TEXT,
instructions TEXT,
due_date TIMESTAMP WITH TIME ZONE,
max_score REAL DEFAULT 100,
allow_late_submission INTEGER DEFAULT 0,
is_published INTEGER DEFAULT 0,
status TEXT DEFAULT 'draft' CHECK(status IN ('draft','published','closed')),
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_synced_at TIMESTAMP WITH TIME ZONE,
sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict')),
is_deleted INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS homework_submissions (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid TEXT UNIQUE,
homework_id BIGINT NOT NULL REFERENCES homework(id),
student_id INTEGER NOT NULL REFERENCES users(id),
content TEXT,
submitted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'submitted' CHECK(status IN ('submitted','graded','returned','late')),
grade REAL,
feedback TEXT,
graded_by INTEGER REFERENCES users(id),
graded_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_synced_at TIMESTAMP WITH TIME ZONE,
sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict')),
is_deleted INTEGER DEFAULT 0,
UNIQUE(homework_id, student_id)
);
CREATE TABLE IF NOT EXISTS attachments (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid TEXT UNIQUE,
parent_kind TEXT NOT NULL CHECK(parent_kind IN ('subject','assignment','homework','test','exam_paper','submission','marked_script')),
parent_id INTEGER NOT NULL,
original_filename TEXT NOT NULL,
stored_filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
uploaded_by INTEGER REFERENCES users(id),
uploaded_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
caption TEXT,
last_synced_at TIMESTAMP WITH TIME ZONE,
sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict')),
is_deleted INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS tests (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid TEXT UNIQUE,
subject_id INTEGER NOT NULL REFERENCES subjects(id),
class_id INTEGER REFERENCES classes(id),
title TEXT NOT NULL,
description TEXT,
instructions TEXT,
test_date TIMESTAMP WITH TIME ZONE,
duration_minutes INTEGER,
max_score REAL DEFAULT 100,
allow_late_submission INTEGER DEFAULT 0,
is_published INTEGER DEFAULT 0,
status TEXT DEFAULT 'draft' CHECK(status IN ('draft','published','closed')),
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_synced_at TIMESTAMP WITH TIME ZONE,
sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict')),
is_deleted INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS test_submissions (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid TEXT UNIQUE,
test_id BIGINT NOT NULL REFERENCES tests(id),
student_id INTEGER NOT NULL REFERENCES users(id),
content TEXT,
submitted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'submitted' CHECK(status IN ('submitted','graded','returned','late')),
grade REAL,
feedback TEXT,
graded_by INTEGER REFERENCES users(id),
graded_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_synced_at TIMESTAMP WITH TIME ZONE,
sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict')),
is_deleted INTEGER DEFAULT 0,
UNIQUE(test_id, student_id)
);
CREATE TABLE IF NOT EXISTS chat_messages (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid TEXT UNIQUE,
conversation_id TEXT,
sender_id INTEGER REFERENCES users(id),
recipient_id INTEGER REFERENCES users(id),
message TEXT,
read_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_synced_at TIMESTAMP WITH TIME ZONE,
sync_status TEXT DEFAULT 'pending',
is_deleted INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS student_medical_profiles (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid TEXT UNIQUE,
student_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
blood_group TEXT CHECK(blood_group IN ('A+','A-','B+','B-','AB+','AB-','O+','O-','Unknown')),
allergies TEXT,
dietary_restrictions TEXT,
contraindications TEXT,
chronic_conditions TEXT,
emergency_instructions TEXT,
updated_by INTEGER REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_synced_at TIMESTAMP WITH TIME ZONE,
sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict')),
is_deleted INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS student_medication_logs (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid TEXT UNIQUE,
student_id INTEGER NOT NULL REFERENCES users(id),
medication_name TEXT NOT NULL,
dosage TEXT NOT NULL,
administered_at TIMESTAMP WITH TIME ZONE NOT NULL,
administered_by INTEGER NOT NULL REFERENCES users(id),
reason TEXT,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_synced_at TIMESTAMP WITH TIME ZONE,
sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict')),
is_deleted INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS student_medical_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid TEXT UNIQUE,
student_id INTEGER NOT NULL REFERENCES users(id),
event_type TEXT NOT NULL CHECK(event_type IN ('illness','injury','hospitalization')),
description TEXT NOT NULL,
severity TEXT DEFAULT 'moderate' CHECK(severity IN ('mild','moderate','severe')),
onset_date DATE,
resolution_date DATE,
treatment_details TEXT,
doctor_notes TEXT,
is_visible_to_teachers INTEGER DEFAULT 1,
reported_by INTEGER REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_synced_at TIMESTAMP WITH TIME ZONE,
sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict')),
is_deleted INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS education_level_configs (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid TEXT UNIQUE,
level TEXT NOT NULL CHECK(level IN ('primary','secondary','high_school','tertiary')),
grading_scale TEXT,
display_name TEXT,
sort_order INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
is_default INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_synced_at TIMESTAMP WITH TIME ZONE,
sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict')),
is_deleted INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS exam_review_audit (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid TEXT UNIQUE,
exam_group_id INTEGER NOT NULL REFERENCES exam_groups(id) ON DELETE CASCADE,
schedule_id INTEGER REFERENCES exam_schedules(id) ON DELETE SET NULL,
action TEXT NOT NULL CHECK(action IN ('submit','reject','publish')),
actor_id INTEGER NOT NULL REFERENCES users(id),
reason TEXT,
metadata TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_synced_at TIMESTAMP WITH TIME ZONE,
sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict')),
is_deleted INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_exam_review_audit_group ON exam_review_audit(exam_group_id, created_at DESC);
CREATE TABLE IF NOT EXISTS notifications (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid TEXT UNIQUE,
recipient_id INTEGER NOT NULL REFERENCES users(id),
type TEXT NOT NULL CHECK(type IN ('in_app','email','sms')),
subject TEXT,
body TEXT,
related_resource TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','sent','failed','read')),
sent_at TIMESTAMP WITH TIME ZONE,
read_at TIMESTAMP WITH TIME ZONE,
error_message TEXT,
metadata TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_synced_at TIMESTAMP WITH TIME ZONE,
sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict')),
is_deleted INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_notifications_recipient_unread ON notifications(recipient_id, status, created_at DESC) WHERE is_deleted = 0;
ALTER TABLE lesson_plans ADD COLUMN IF NOT EXISTS meet_url TEXT;
ALTER TABLE lesson_plans ADD COLUMN IF NOT EXISTS classroom_url TEXT;
ALTER TABLE student_fees ADD COLUMN IF NOT EXISTS plan_id INTEGER REFERENCES fee_plans(id);
ALTER TABLE student_fees ADD COLUMN IF NOT EXISTS applied_by INTEGER REFERENCES users(id);
ALTER TABLE student_fees ADD COLUMN IF NOT EXISTS academic_year TEXT;
ALTER TABLE exam_groups ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'approved';
ALTER TABLE exam_groups ADD COLUMN IF NOT EXISTS rejection_reason TEXT;
ALTER TABLE exam_groups ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMP WITH TIME ZONE;
ALTER TABLE exam_groups ADD COLUMN IF NOT EXISTS reviewed_by INTEGER REFERENCES users(id);
ALTER TABLE grades ADD COLUMN IF NOT EXISTS grading_scale TEXT;
ALTER TABLE exam_groups ADD COLUMN IF NOT EXISTS grading_scale TEXT;
ALTER TABLE course_grades ADD COLUMN IF NOT EXISTS grading_scale TEXT;
-- =====================================================
-- 3. tenant_id INJECTION (idempotent ALTER ADD COLUMN IF NOT EXISTS)
-- =====================================================
DO $$
DECLARE
t_name TEXT;
domain_tables TEXT[] := ARRAY[
'users','departments','classes','subjects','subjects_new','courses','enrollments','course_enrolments','attendance','fee_groups','student_fees','payments','messages','events','expenses','grades','course_grades','assignments','submissions','homework','homework_submissions','attachments','tests','test_submissions','settings','system_settings','school_settings','audit_logs','staff_roles','salary_grades','staff_records','vacancies','applicants','leave_types','leave_requests','staff_attendance','payroll_runs','payslips','item_categories','suppliers','store_locations','items','item_stock','stock_transactions','item_issues','exam_groups','question_banks','exam_schedules','exam_attempts','exam_answers','exam_review_audit','notifications','hostels','room_types','rooms','room_assignments','hostel_fees','vehicles','routes','pickup_points','vehicle_routes','transport_allocations','admission_enquiries','visitor_logs','phone_call_logs','postal_dispatch','complaints','clubs','club_memberships','club_announcements','club_attendance','club_chats','chat_groups','chat_group_members','chat_messages','hostel_attendance','transport_attendance','parent_students','syllabuses','schemes_of_work','focus_points','lesson_plans','student_social_docs','student_medical_profiles','student_medication_logs','student_medical_history','crossword_games','crossword_clues','crossword_sessions','student_groups','student_group_members','chart_of_accounts','bank_accounts','bank_reconciliations','bank_reconciliation_lines','invoices','trips','trip_payments','fee_plans','fee_plan_installments','discounts','student_fee_discounts','library_books','library_issues','library_reservations','library_fines','education_level_configs','tenant_licenses'
];
BEGIN
FOREACH t_name IN ARRAY domain_tables LOOP
IF to_regclass(format('public.%I', t_name)) IS NOT NULL THEN
EXECUTE format('ALTER TABLE public.%I ADD COLUMN IF NOT EXISTS tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE', t_name);
END IF;
END LOOP;
END $$;
-- =====================================================
-- 4. INDEXES on tenant_id
-- =====================================================
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT c.table_name
FROM information_schema.columns c
WHERE c.table_schema = 'public' AND c.column_name = 'tenant_id' AND c.table_name <> 'tenants'
LOOP
EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON public.%I(tenant_id)', 'idx_' || r.table_name || '_tenant_id', r.table_name);
END LOOP;
END $$;
-- =====================================================
-- 5. RLS ENABLE + POLICIES
-- =====================================================
DO $$
DECLARE
r RECORD;
tenant_expr TEXT := '(tenant_id = ((auth.jwt() -> ''user_metadata'' ->> ''tenant_id'')::uuid) OR auth.jwt() ->> ''role'' = ''super_admin'' OR current_setting(''app.is_super_admin'', true) = ''true'')';
BEGIN
FOR r IN
SELECT c.table_name
FROM information_schema.columns c
WHERE c.table_schema = 'public' AND c.column_name = 'tenant_id'
AND c.table_name NOT IN ('tenants','sync_logs','sync_metadata','sync_config')
LOOP
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', r.table_name);
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', 'tenant_isolation_' || r.table_name, r.table_name);
EXECUTE format('CREATE POLICY %I ON public.%I FOR ALL TO authenticated USING (%s) WITH CHECK (%s)', 'tenant_isolation_' || r.table_name, r.table_name, tenant_expr, tenant_expr);
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', 'service_role_full_access_' || r.table_name, r.table_name);
EXECUTE format('CREATE POLICY %I ON public.%I FOR ALL TO service_role USING (true) WITH CHECK (true)', 'service_role_full_access_' || r.table_name, r.table_name);
END LOOP;
END $$;
-- =====================================================
-- 6. STORAGE POLICIES (mirror supabase_migration.md §4)
-- =====================================================
DO $$
BEGIN
IF to_regclass('storage.buckets') IS NOT NULL THEN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='storage' AND tablename='buckets' AND policyname='Allow authenticated bucket management') THEN
CREATE POLICY "Allow authenticated bucket management" ON storage.buckets FOR ALL TO authenticated USING (true) WITH CHECK (true);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='storage' AND tablename='buckets' AND policyname='Allow public bucket read') THEN
CREATE POLICY "Allow public bucket read" ON storage.buckets FOR SELECT TO anon, authenticated USING (true);
END IF;
END IF;
IF to_regclass('storage.objects') IS NOT NULL THEN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='storage' AND tablename='objects' AND policyname='Allow authenticated object management') THEN
CREATE POLICY "Allow authenticated object management" ON storage.objects FOR ALL TO authenticated USING (true) WITH CHECK (true);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='storage' AND tablename='objects' AND policyname='Allow public object read') THEN
CREATE POLICY "Allow public object read" ON storage.objects FOR SELECT TO anon, authenticated USING (true);
END IF;
END IF;
END $$;
-- =====================================================
-- 7. DROP LIST — UNCOMMENT BEFORE RUNNING
-- Each line tagged with its FK risk; the user must
-- verify before uncommenting.
-- =====================================================
-- Canonical tables are subjects, attendance, messages, and events.
-- subjects_new is NOT in this list: Knex explicitly creates it and cloud courses,
-- crossword_games, schemes_of_work, lesson_plans, syllabuses, and edutainment tables reference it.
-- attendance_new: referenced only by no Knex-defined table; its own FKs target users/subjects_new.
-- DROP TABLE IF EXISTS attendance_new CASCADE;
-- messages_new: referenced only by no Knex-defined table; its own FKs target users.
-- DROP TABLE IF EXISTS messages_new CASCADE;
-- calendar_events: referenced only by no Knex-defined table; its own FK targets users.
-- DROP TABLE IF EXISTS calendar_events CASCADE;
-- assignment_submissions: overlaps canonical submissions; its own FKs target assignments/users.
-- DROP TABLE IF EXISTS assignment_submissions CASCADE;
-- edutainment_content: no Knex-defined inbound FK; its own FKs target subjects_new/users.
-- DROP TABLE IF EXISTS edutainment_content CASCADE;
-- edutainment_sessions must be dropped before/with edutainment_games; it references edutainment_games/users.
-- DROP TABLE IF EXISTS edutainment_sessions CASCADE;
-- edutainment_games is referenced by edutainment_sessions; CASCADE removes that FK.
-- DROP TABLE IF EXISTS edutainment_games CASCADE;
-- =====================================================
-- 8. SEED test tenant
-- =====================================================
INSERT INTO tenants (id, school_name)
VALUES ('5b39bcb5-506a-456d-b7cb-91730b397cc1', 'Default School Tenant')
ON CONFLICT (id) DO NOTHING;
INSERT INTO education_level_configs (uid, level, grading_scale, display_name, sort_order, is_active, is_default, tenant_id)
VALUES
('uid-elc-primary','primary','zimsec_primary','Primary',10,1,0,'5b39bcb5-506a-456d-b7cb-91730b397cc1'),
('uid-elc-secondary','secondary','zimsec_olevel','Secondary (O-Level)',20,1,1,'5b39bcb5-506a-456d-b7cb-91730b397cc1'),
('uid-elc-highschool','high_school','zimsec_alevel','High School (A-Level)',30,1,0,'5b39bcb5-506a-456d-b7cb-91730b397cc1'),
('uid-elc-tertiary','tertiary','cambridge_alevel','Tertiary',40,1,0,'5b39bcb5-506a-456d-b7cb-91730b397cc1')
ON CONFLICT (uid) DO NOTHING;