-- ===================================================== -- 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;