New teachers awaiting your confirmation before they can access the platform.
✅
No pending approvals.
All Teachers
Manage accounts, roles, and access.
Active: —·Pending: —·Total: —
👥
No teachers found.
All Exams — Platform Wide
Every assessment created by all teachers.
Subject
Teacher
Class
Term
Type
Status
Questions
Created
Actions
📋
No exams found.
All Results — Platform Wide
Every student submission across all teachers.
Student
Class
Subject
Teacher
Score
%
Mode
Integrity
Time
Date
Actions
🔍
No results found.
Platform Activity Log
📈
No activity recorded yet.
🛡️ Security & Deployment Centre
Free browser-based checks for admin readiness, RLS/RPC availability, static deployment assets, and export tools. No paid AI API.
Feature Explanation
This section helps the school owner/admin verify that the platform is deployed safely. It checks whether admin session exists, RPC data loaded, HTTPS is active, service-role keys are not exposed, required PWA/static files exist, and whether platform records are accessible. It also provides downloadable operational checklists.
RLS-firstNo service_role keyFree hosting readyNo AI API
⚙️ Complete CBT Pro Setup Guide
This guide contains every SQL statement needed to set up CBT Pro from scratch on any Supabase project. Follow every step in order. Each step explains what it does and why.
📌 How to run SQL in Supabase:
1. Go to supabase.com and open your project
2. Click SQL Editor in the left sidebar
3. Click New Query
4. Paste the SQL block and click Run
5. A green "Success" message confirms it worked
6. Run each step below in order — do not skip any step
📋 All Steps at a Glance
Step 1 — Create the exams table Step 2 — Create the results table Step 3 — Create the profiles table (teacher accounts) Step 4 — Create the students table (class rosters) Step 5 — Enable Row Level Security on all tables Step 6 — RLS policies for exams (data isolation per teacher) Step 7 — RLS policies for results (data isolation per teacher) Step 8 — RLS policies for profiles Step 9 — RLS policies for students Step 10 — Grant permissions so the trigger can write profiles Step 11 — Auto-create pending profile on teacher signup (FIXED trigger) Step 12 — Admin RPC functions (admin sees all teachers and data) Step 13 — Migrate existing users (if platform already has teachers) Step 14 — Set up the admin account Step 15 — Disable email confirmation for instant teacher access Step 16 — Verify everything is working
Step 1 — Create the Exams Table
This table stores every exam created by every teacher. Each row is one exam.
• teacher_id — links each exam to the teacher who created it. Used by RLS to isolate data.
• subject — stores all metadata (subject, class, term, topic, type, session, passmark) as a pipe-separated string.
• csv_data — stores the entire question bank as JSON inside the database.
• exam_mode — either 'open' (anyone can sit) or 'registered' (verified students only).
• close_at — optional scheduled auto-close date and time.
• is_open — controls whether students can currently access the exam.
CREATE TABLE IF NOT EXISTS public.exams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
teacher_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
code TEXT UNIQUE NOT NULL,
subject TEXT NOT NULL,
duration INTEGER NOT NULL DEFAULT 45,
attempt_limit INTEGER NOT NULL DEFAULT 1,
select_count INTEGER NOT NULL DEFAULT 0,
is_open BOOLEAN NOT NULL DEFAULT false,
exam_mode TEXT NOT NULL DEFAULT 'open',
negative_mark NUMERIC NOT NULL DEFAULT 0,
release_results BOOLEAN NOT NULL DEFAULT true,
instructions TEXT NOT NULL DEFAULT '',
is_archived BOOLEAN NOT NULL DEFAULT false,
cert_code TEXT NOT NULL DEFAULT '',
start_at TIMESTAMPTZ,
close_at TIMESTAMPTZ,
csv_data JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
Step 2 — Create the Results Table
This table stores every student submission. Each row is one student's completed exam attempt.
• exam_id — links the result to its exam. If the exam is deleted, results are also deleted (CASCADE).
• student_id_ref — the school ID used by registered-mode students to verify identity.
• student_type — either 'open' (anonymous) or 'registered' (identity verified).
• answers_data — JSON storing each question's answer, time spent, and flag status.
• violations and violation_log — anti-cheat integrity data recorded during the exam.
CREATE TABLE IF NOT EXISTS public.results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
exam_id UUID NOT NULL REFERENCES public.exams(id) ON DELETE CASCADE,
student_name TEXT NOT NULL,
student_class TEXT NOT NULL DEFAULT '',
student_id_ref TEXT DEFAULT '',
student_type TEXT DEFAULT 'open',
score NUMERIC(10,2) NOT NULL DEFAULT 0,
total INTEGER NOT NULL DEFAULT 0,
correct_count INTEGER DEFAULT NULL,
wrong_count INTEGER DEFAULT NULL,
skipped_count INTEGER DEFAULT NULL,
attempt_number INTEGER DEFAULT 1,
time_taken INTEGER DEFAULT 0,
answers_data JSONB,
violations INTEGER DEFAULT 0,
violation_log JSONB DEFAULT '[]'::jsonb,
proctor_data JSONB,
cert_code TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
);
Step 3 — Create the Profiles Table
This table manages teacher accounts. Every teacher has exactly one row here.
• status controls login access: 'pending' (awaiting admin approval), 'active' (can log in), 'inactive' (suspended).
• role is either 'teacher' or 'admin'.
When a teacher signs up, the trigger in Step 11 automatically creates a pending row here.
The admin must approve from the Pending Approvals page before the teacher can access the dashboard.
CREATE TABLE IF NOT EXISTS public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT UNIQUE NOT NULL,
full_name TEXT DEFAULT '',
role TEXT NOT NULL DEFAULT 'teacher',
is_admin BOOLEAN NOT NULL DEFAULT false,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
Step 4 — Create the Students Table
This table stores each teacher's class roster for registered-mode exams.
• student_id — the school ID students enter to verify their identity (e.g. SS/2024/001).
• UNIQUE (teacher_id, student_id) — prevents duplicate student IDs within the same teacher's roster.
Students are not Supabase users — they are just records that the teacher manages.
-- Registered-student roster table and secure teacher/admin policies.
CREATE TABLE IF NOT EXISTS public.students (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
teacher_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
full_name TEXT NOT NULL,
student_id TEXT NOT NULL,
class TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(teacher_id, student_id)
);
Step 5 — Enable Row Level Security (RLS) on All Tables
Row Level Security is PostgreSQL's built-in data isolation system. When enabled, Supabase automatically enforces that each user can only access rows they are permitted to see — even if someone sends a direct API request.
This is the most important step. Without it, every teacher sees every other teacher's exams, results, and students.
Run this block to enable RLS on all four tables at once.
-- Enable RLS on every application table
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.exams ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.results ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.students ENABLE ROW LEVEL SECURITY;
Step 6 — RLS Policies for the Exams Table
These four policies enforce that each teacher can only read, create, edit, and delete their own exams.
The condition auth.uid() = teacher_id means Supabase checks that the logged-in teacher's user ID matches the exam's teacher_id column on every single request — automatically, without any JavaScript needed.
-- Helper required before any admin-aware policy below.
CREATE OR REPLACE FUNCTION public.is_platform_admin()
RETURNS BOOLEAN
LANGUAGE SQL SECURITY DEFINER STABLE
SET search_path = public, pg_temp
AS $$
SELECT COALESCE((
SELECT (p.is_admin=true OR p.role='admin') AND p.status='active'
FROM public.profiles p WHERE p.id=auth.uid() LIMIT 1
), false)
OR lower(COALESCE(auth.jwt()->>'email','')) = lower('buildingmyictcareer@gmail.com');
$$;
-- Exams: teachers manage their own exams; platform admins can supervise all.
-- Students load exams through get_public_exam_by_code(), not broad anonymous SELECT.
DROP POLICY IF EXISTS "Students can read exams by code" ON public.exams;
DROP POLICY IF EXISTS "Teachers select own exams" ON public.exams;
DROP POLICY IF EXISTS "Teachers insert own exams" ON public.exams;
DROP POLICY IF EXISTS "Teachers update own exams" ON public.exams;
DROP POLICY IF EXISTS "Teachers delete own exams" ON public.exams;
CREATE POLICY "Teachers select own exams"
ON public.exams FOR SELECT TO authenticated
USING (auth.uid() = teacher_id OR public.is_platform_admin());
CREATE POLICY "Teachers insert own exams"
ON public.exams FOR INSERT TO authenticated
WITH CHECK (auth.uid() = teacher_id OR public.is_platform_admin());
CREATE POLICY "Teachers update own exams"
ON public.exams FOR UPDATE TO authenticated
USING (auth.uid() = teacher_id OR public.is_platform_admin())
WITH CHECK (auth.uid() = teacher_id OR public.is_platform_admin());
CREATE POLICY "Teachers delete own exams"
ON public.exams FOR DELETE TO authenticated
USING (auth.uid() = teacher_id OR public.is_platform_admin());
Step 7 — RLS Policies for the Results Table
A result belongs to a teacher if it was submitted to an exam that teacher created.
The sub-query (SELECT teacher_id FROM exams WHERE id = exam_id) traces from the result back to its exam to find the owner.
The student INSERT policy uses public.is_exam_open_for_submission(exam_id), so anonymous students can submit only to an open, active, non-expired exam. This prevents submissions to closed or archived exams.
-- Results: create helper functions BEFORE policies, then create safe policies.
CREATE OR REPLACE FUNCTION public.get_exam_teacher_id(p_exam_id UUID)
RETURNS UUID
LANGUAGE SQL SECURITY DEFINER STABLE
SET search_path = public, pg_temp
AS $$ SELECT teacher_id FROM public.exams WHERE id = p_exam_id LIMIT 1; $$;
CREATE OR REPLACE FUNCTION public.is_exam_open_for_submission(p_exam_id UUID)
RETURNS BOOLEAN
LANGUAGE SQL SECURITY DEFINER STABLE
SET search_path = public, pg_temp
AS $$
SELECT EXISTS (
SELECT 1 FROM public.exams e
WHERE e.id = p_exam_id
AND e.is_archived = false
AND e.is_open = true
AND (e.start_at IS NULL OR e.start_at <= NOW())
AND (e.close_at IS NULL OR e.close_at > NOW())
);
$$;
DROP POLICY IF EXISTS "Teachers select own results" ON public.results;
DROP POLICY IF EXISTS "Students can submit results" ON public.results;
DROP POLICY IF EXISTS "Teachers insert own results" ON public.results;
DROP POLICY IF EXISTS "Teachers update own results" ON public.results;
DROP POLICY IF EXISTS "Teachers delete own results" ON public.results;
CREATE POLICY "Teachers select own results"
ON public.results FOR SELECT TO authenticated
USING (auth.uid() = public.get_exam_teacher_id(exam_id) OR public.is_platform_admin());
CREATE POLICY "Students can submit results"
ON public.results FOR INSERT TO anon
WITH CHECK (public.is_exam_open_for_submission(exam_id));
CREATE POLICY "Teachers insert own results"
ON public.results FOR INSERT TO authenticated
WITH CHECK (auth.uid() = public.get_exam_teacher_id(exam_id) OR public.is_platform_admin());
CREATE POLICY "Teachers update own results"
ON public.results FOR UPDATE TO authenticated
USING (auth.uid() = public.get_exam_teacher_id(exam_id) OR public.is_platform_admin())
WITH CHECK (auth.uid() = public.get_exam_teacher_id(exam_id) OR public.is_platform_admin());
CREATE POLICY "Teachers delete own results"
ON public.results FOR DELETE TO authenticated
USING (auth.uid() = public.get_exam_teacher_id(exam_id) OR public.is_platform_admin());
Step 8 — RLS Policies for the Profiles Table
Each teacher can only read and update their own profile row.
The INSERT policy is open so the auto-signup trigger (Step 11) can create profile rows during signup.
Admins read ALL profiles through the special RPC functions in Step 12 — not through these policies.
CREATE OR REPLACE FUNCTION public.is_platform_admin()
RETURNS BOOLEAN
LANGUAGE SQL SECURITY DEFINER STABLE
SET search_path = public, pg_temp
AS $$
SELECT COALESCE((
SELECT (p.is_admin=true OR p.role='admin') AND p.status='active'
FROM public.profiles p WHERE p.id=auth.uid() LIMIT 1
), false)
OR lower(COALESCE(auth.jwt()->>'email','')) = lower('buildingmyictcareer@gmail.com');
$$;
DROP POLICY IF EXISTS "Users read own profile" ON public.profiles;
DROP POLICY IF EXISTS "Allow profile insert" ON public.profiles;
DROP POLICY IF EXISTS "Users update own profile" ON public.profiles;
DROP POLICY IF EXISTS "Admins manage all profiles" ON public.profiles;
CREATE POLICY "Users read own profile" ON public.profiles FOR SELECT TO authenticated
USING (auth.uid() = id OR public.is_platform_admin());
CREATE POLICY "Allow profile insert" ON public.profiles FOR INSERT TO authenticated
WITH CHECK (auth.uid() = id OR public.is_platform_admin());
CREATE POLICY "Users update own profile" ON public.profiles FOR UPDATE TO authenticated
USING (auth.uid() = id OR public.is_platform_admin())
WITH CHECK (auth.uid() = id OR public.is_platform_admin());
CREATE POLICY "Admins manage all profiles" ON public.profiles FOR DELETE TO authenticated
USING (public.is_platform_admin());
Step 9 — RLS Policies for the Students Table
Each teacher can only read, add, and delete students from their own roster.
Registered-mode student ID checks now use the secure verify_student_for_exam() RPC. Do not create a broad anonymous roster SELECT policy; otherwise students could enumerate the whole class list.
-- Registered-student roster table and secure teacher/admin policies.
CREATE TABLE IF NOT EXISTS public.students (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
teacher_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
full_name TEXT NOT NULL,
student_id TEXT NOT NULL,
class TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(teacher_id, student_id)
);
DROP POLICY IF EXISTS "Anyone can verify student ID" ON public.students;
DROP POLICY IF EXISTS "Teachers select own students" ON public.students;
DROP POLICY IF EXISTS "Teachers insert own students" ON public.students;
DROP POLICY IF EXISTS "Teachers update own students" ON public.students;
DROP POLICY IF EXISTS "Teachers delete own students" ON public.students;
CREATE POLICY "Teachers select own students"
ON public.students FOR SELECT TO authenticated
USING (auth.uid() = teacher_id OR public.is_platform_admin());
CREATE POLICY "Teachers insert own students"
ON public.students FOR INSERT TO authenticated
WITH CHECK (auth.uid() = teacher_id OR public.is_platform_admin());
CREATE POLICY "Teachers update own students"
ON public.students FOR UPDATE TO authenticated
USING (auth.uid() = teacher_id OR public.is_platform_admin())
WITH CHECK (auth.uid() = teacher_id OR public.is_platform_admin());
CREATE POLICY "Teachers delete own students"
ON public.students FOR DELETE TO authenticated
USING (auth.uid() = teacher_id OR public.is_platform_admin());
Step 10 — Grant Permissions for the Trigger Function
This step grants the postgres and service_role users full access to the profiles table.
Without this, the auto-signup trigger in Step 11 will fail with a "database error" when a new teacher tries to create an account — even though the trigger is correctly written.
This step is why signups were failing. Run this before creating the trigger.
GRANT USAGE ON SCHEMA public TO anon, authenticated;
GRANT SELECT, INSERT ON public.results TO anon;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.exams TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.results TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.students TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.profiles TO authenticated;
Step 11 — Auto-Create Pending Profile on Teacher Signup (Verified Working Trigger)
This is a PostgreSQL trigger — a function that runs automatically inside the database every time a new user is created in Supabase Auth.
When a teacher clicks "Create Account", Supabase creates their auth record, and this trigger immediately creates a profile row with status = 'pending'.
The teacher sees "Awaiting Approval" and cannot access the dashboard until the admin approves them.
Key details of this version: • SET search_path = public — ensures the trigger always finds the profiles table regardless of schema context.
• EXCEPTION WHEN others — if the profile insert fails for any reason, the signup still succeeds. The teacher gets a pending profile on their first login attempt instead.
• SECURITY DEFINER — the function runs with the privileges of its creator (postgres), not the signing-up user.
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
DROP FUNCTION IF EXISTS public.handle_new_user();
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER
LANGUAGE PLPGSQL SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
BEGIN
INSERT INTO public.profiles (id, email, full_name, role, is_admin, status)
VALUES (
NEW.id,
NEW.email,
COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.raw_user_meta_data->>'display_name', split_part(NEW.email,'@',1)),
CASE WHEN lower(NEW.email)=lower('buildingmyictcareer@gmail.com') THEN 'admin' ELSE 'teacher' END,
CASE WHEN lower(NEW.email)=lower('buildingmyictcareer@gmail.com') THEN true ELSE false END,
CASE WHEN lower(NEW.email)=lower('buildingmyictcareer@gmail.com') THEN 'active' ELSE 'pending' END
)
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
EXCEPTION WHEN others THEN
RAISE WARNING 'handle_new_user failed: %', SQLERRM;
RETURN NEW;
END;
$$;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
Step 12 — Admin RPC Functions (Required — Without This Admin Sees Zero Teachers)
These are special database functions that allow the admin panel to read and modify ALL data across ALL teachers — bypassing RLS safely.
Without these functions, the admin panel shows zero teachers, zero exams, and zero results because normal RLS blocks cross-teacher access.
• SECURITY DEFINER — these functions execute with database owner privileges, not the caller's.
• The SELECT functions let the admin read everything. The UPDATE/DELETE functions let the admin approve, suspend, promote, or remove teachers.
Run the entire block as a single query.
-- STEP 10: ADMIN RPC FUNCTIONS
-- ═══════════════════════════════════════════════════════════════════
DROP FUNCTION IF EXISTS public.admin_get_all_profiles() CASCADE;
DROP FUNCTION IF EXISTS public.admin_get_all_exams() CASCADE;
DROP FUNCTION IF EXISTS public.admin_get_all_results() CASCADE;
DROP FUNCTION IF EXISTS public.admin_set_profile_status(UUID, TEXT) CASCADE;
DROP FUNCTION IF EXISTS public.admin_set_profile_role(UUID, TEXT, TEXT) CASCADE;
DROP FUNCTION IF EXISTS public.admin_delete_profile(UUID) CASCADE;
DROP FUNCTION IF EXISTS public.admin_get_platform_stats() CASCADE;
DROP FUNCTION IF EXISTS public.admin_get_exam_results(UUID) CASCADE;
CREATE OR REPLACE FUNCTION public.admin_get_all_profiles()
RETURNS SETOF public.profiles
LANGUAGE PLPGSQL
SECURITY DEFINER
STABLE
SET search_path = public, pg_temp
AS $$
BEGIN
IF NOT public.is_platform_admin() THEN
RAISE EXCEPTION 'Not authorized: admin access required';
END IF;
RETURN QUERY SELECT * FROM public.profiles ORDER BY created_at DESC;
END;
$$;
CREATE OR REPLACE FUNCTION public.admin_get_all_exams()
RETURNS SETOF public.exams
LANGUAGE PLPGSQL
SECURITY DEFINER
STABLE
SET search_path = public, pg_temp
AS $$
BEGIN
IF NOT public.is_platform_admin() THEN
RAISE EXCEPTION 'Not authorized: admin access required';
END IF;
RETURN QUERY SELECT * FROM public.exams ORDER BY created_at DESC;
END;
$$;
CREATE OR REPLACE FUNCTION public.admin_get_all_results()
RETURNS TABLE (
id UUID,
exam_id UUID,
student_name TEXT,
student_class TEXT,
student_id_ref TEXT,
student_type TEXT,
score NUMERIC,
total INTEGER,
correct_count INTEGER,
wrong_count INTEGER,
skipped_count INTEGER,
attempt_number INTEGER,
time_taken INTEGER,
answers_data JSONB,
violations INTEGER,
violation_log JSONB,
proctor_data JSONB,
cert_code TEXT,
created_at TIMESTAMPTZ,
exams JSONB
)
LANGUAGE PLPGSQL
SECURITY DEFINER
STABLE
SET search_path = public, pg_temp
AS $$
BEGIN
IF NOT public.is_platform_admin() THEN
RAISE EXCEPTION 'Not authorized: admin access required';
END IF;
RETURN QUERY
SELECT
r.id, r.exam_id, r.student_name, r.student_class,
r.student_id_ref, r.student_type, r.score, r.total,
r.correct_count, r.wrong_count, r.skipped_count,
r.attempt_number, r.time_taken, r.answers_data,
r.violations, r.violation_log, r.proctor_data,
r.cert_code, r.created_at,
jsonb_build_object(
'subject', e.subject,
'teacher_id', e.teacher_id,
'code', e.code
) AS exams
FROM public.results r
LEFT JOIN public.exams e ON e.id = r.exam_id
ORDER BY r.created_at DESC;
END;
$$;
CREATE OR REPLACE FUNCTION public.admin_set_profile_status(p_id UUID, p_status TEXT)
RETURNS VOID
LANGUAGE PLPGSQL
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
BEGIN
IF NOT public.is_platform_admin() THEN
RAISE EXCEPTION 'Not authorized: admin access required';
END IF;
IF p_status NOT IN ('pending', 'active', 'inactive', 'rejected') THEN
RAISE EXCEPTION 'Invalid status: %', p_status;
END IF;
UPDATE public.profiles
SET status = p_status, updated_at = NOW()
WHERE id = p_id;
END;
$$;
CREATE OR REPLACE FUNCTION public.admin_set_profile_role(p_id UUID, p_role TEXT, p_status TEXT)
RETURNS VOID
LANGUAGE PLPGSQL
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
BEGIN
IF NOT public.is_platform_admin() THEN
RAISE EXCEPTION 'Not authorized: admin access required';
END IF;
IF p_role NOT IN ('teacher', 'admin') THEN
RAISE EXCEPTION 'Invalid role: %', p_role;
END IF;
IF p_status NOT IN ('pending', 'active', 'inactive', 'rejected') THEN
RAISE EXCEPTION 'Invalid status: %', p_status;
END IF;
UPDATE public.profiles
SET is_admin = (p_role = 'admin'),
role = p_role,
status = p_status,
updated_at = NOW()
WHERE id = p_id;
END;
$$;
CREATE OR REPLACE FUNCTION public.admin_delete_profile(p_id UUID)
RETURNS VOID
LANGUAGE PLPGSQL
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
BEGIN
IF NOT public.is_platform_admin() THEN
RAISE EXCEPTION 'Not authorized: admin access required';
END IF;
DELETE FROM public.profiles WHERE id = p_id;
END;
$$;
CREATE OR REPLACE FUNCTION public.admin_get_platform_stats()
RETURNS TABLE (
total_teachers BIGINT,
active_teachers BIGINT,
pending_teachers BIGINT,
total_exams BIGINT,
live_exams BIGINT,
total_results BIGINT,
total_students BIGINT,
avg_score NUMERIC,
pass_rate NUMERIC
)
LANGUAGE PLPGSQL
SECURITY DEFINER
STABLE
SET search_path = public, pg_temp
AS $$
BEGIN
IF NOT public.is_platform_admin() THEN
RAISE EXCEPTION 'Not authorized: admin access required';
END IF;
RETURN QUERY
SELECT
(SELECT COUNT(*) FROM public.profiles WHERE role IN ('teacher', 'admin')) AS total_teachers,
(SELECT COUNT(*) FROM public.profiles WHERE status = 'active') AS active_teachers,
(SELECT COUNT(*) FROM public.profiles WHERE status = 'pending') AS pending_teachers,
(SELECT COUNT(*) FROM public.exams) AS total_exams,
(SELECT COUNT(*) FROM public.exams WHERE is_open = true AND is_archived = false) AS live_exams,
(SELECT COUNT(*) FROM public.results) AS total_results,
(SELECT COUNT(*) FROM public.students) AS total_students,
COALESCE((SELECT ROUND(AVG((score / NULLIF(total, 0)) * 100), 2) FROM public.results WHERE total > 0), 0) AS avg_score,
COALESCE((
SELECT ROUND(
AVG(CASE WHEN (score / NULLIF(total, 0)) * 100 >= 50 THEN 1 ELSE 0 END) * 100,
2
)
FROM public.results
WHERE total > 0
), 0) AS pass_rate;
END;
$$;
CREATE OR REPLACE FUNCTION public.admin_get_exam_results(p_exam_id UUID)
RETURNS TABLE (
id UUID,
exam_id UUID,
student_name TEXT,
student_class TEXT,
student_id_ref TEXT,
student_type TEXT,
score NUMERIC,
total INTEGER,
correct_count INTEGER,
wrong_count INTEGER,
skipped_count INTEGER,
attempt_number INTEGER,
time_taken INTEGER,
answers_data JSONB,
violations INTEGER,
violation_log JSONB,
created_at TIMESTAMPTZ
)
LANGUAGE PLPGSQL
SECURITY DEFINER
STABLE
SET search_path = public, pg_temp
AS $$
BEGIN
IF NOT public.is_platform_admin() THEN
RAISE EXCEPTION 'Not authorized: admin access required';
END IF;
RETURN QUERY
SELECT
r.id, r.exam_id, r.student_name, r.student_class,
r.student_id_ref, r.student_type, r.score, r.total,
r.correct_count, r.wrong_count, r.skipped_count,
r.attempt_number, r.time_taken, r.answers_data,
r.violations, r.violation_log, r.created_at
FROM public.results r
WHERE r.exam_id = p_exam_id
ORDER BY r.created_at DESC;
END;
$$;
These three columns store the correct, wrong, and skipped question counts as computed by the student's browser at exam submission time.
They are the authoritative source of truth — the teacher dashboard reads these directly rather than re-computing from answers_data.
Re-computing from the current question bank can give wrong results if questions were edited after students submitted.
Run this if your teacher dashboard shows different wrong counts than students see on their result screen.
-- Accurate result columns for objective + partial-credit scoring.
ALTER TABLE public.results ADD COLUMN IF NOT EXISTS correct_count INTEGER DEFAULT NULL;
ALTER TABLE public.results ADD COLUMN IF NOT EXISTS wrong_count INTEGER DEFAULT NULL;
ALTER TABLE public.results ADD COLUMN IF NOT EXISTS skipped_count INTEGER DEFAULT NULL;
ALTER TABLE public.results ADD COLUMN IF NOT EXISTS attempt_number INTEGER DEFAULT 1;
ALTER TABLE public.results ALTER COLUMN score TYPE NUMERIC(10,2) USING score::NUMERIC;
Step 13 — Migrate Existing Users (Only for Platforms Already in Use)
Skip this step if you are setting up a brand new platform with no existing users. If you already have teachers registered BEFORE the profiles table was created, they have no profile row and will see "Platform setup incomplete" when they try to log in.
Run this SQL once to create active profile rows for all existing auth users.
Before running: change buildingmyictcareer@gmail.com to your actual admin email address — that account will be set to admin role, everyone else becomes an active teacher.
-- Migrate existing Auth users into profiles. Change the admin email if needed.
INSERT INTO public.profiles (id, email, full_name, role, is_admin, status)
SELECT
u.id,
u.email,
COALESCE(u.raw_user_meta_data->>'full_name', u.raw_user_meta_data->>'display_name', split_part(u.email, '@', 1)),
CASE WHEN lower(u.email) = lower('buildingmyictcareer@gmail.com') THEN 'admin' ELSE 'teacher' END,
CASE WHEN lower(u.email) = lower('buildingmyictcareer@gmail.com') THEN true ELSE false END,
'active'
FROM auth.users u
WHERE NOT EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = u.id)
ON CONFLICT (id) DO NOTHING;
Step 14 — Set Up the Admin Account
This ensures your admin account has the correct role and active status in the profiles table.
How to get your UUID: Go to Supabase Dashboard → Authentication → Users, find your admin email in the list, and copy the UUID shown next to it.
Paste your UUID in place of YOUR-UUID-HERE below, update the email, then run the query.
-- Replace YOUR-UUID-HERE with your UUID from Supabase Auth → Users.
INSERT INTO public.profiles (id, email, full_name, role, is_admin, status)
VALUES (
'YOUR-UUID-HERE',
'buildingmyictcareer@gmail.com',
'Adewale Samson Adeagbo',
'admin',
true,
'active'
)
ON CONFLICT (id) DO UPDATE
SET role='admin', is_admin=true, status='active', updated_at=NOW();
Step 15 — Disable Email Confirmation (Recommended for School Use)
By default, Supabase requires new users to click a confirmation link in their email before their account is active.
For a school platform where the admin manually approves every teacher anyway, this extra step is unnecessary and can cause confusion.
This is NOT SQL — do it in the Supabase Dashboard: 1. Go to Supabase Dashboard → Authentication → Providers 2. Click on Email 3. Toggle OFF the "Confirm email" switch
4. Click Save After this, new teachers who sign up will immediately see "Awaiting Approval" instead of a verification email, and the admin approves them from this panel.
📍 Supabase Dashboard → Authentication → Providers → Email → Confirm email → OFF → Save
Step 16 — Verify Everything Is Working
Run these queries to confirm all tables exist, RLS is enabled on all of them, and your profiles are correctly set up.
• The first query should return 4 rows — one for each table — all with rowsecurity = true.
• The second query shows all profiles with their roles and statuses.
• The third query confirms the trigger is attached.
-- Verification checks
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname='public' AND tablename IN ('profiles','exams','results','students')
ORDER BY tablename;
SELECT tablename, policyname, cmd, roles
FROM pg_policies
WHERE schemaname='public'
ORDER BY tablename, policyname;
SELECT routine_name
FROM information_schema.routines
WHERE routine_schema='public'
AND routine_name IN ('get_public_exam_by_code','verify_student_for_exam','get_exam_attempt_count','is_platform_admin')
ORDER BY routine_name;
📋 Question Bank CSV Format Reference
When uploading questions via CSV, your file must follow this exact column order.
Row 1 is treated as the header and is automatically skipped.
The Explanation column is optional but recommended — it helps students learn from their mistakes.
Column order: Question, A, B, C, D, CorrectAnswer, Explanation
Rules:
- CorrectAnswer must be exactly A, B, C, or D (uppercase)
- Wrap any field containing a comma in double quotes
- One question per row — no blank rows between questions
Example rows:
"What is 2 squared?","2","4","6","8","B","2² = 2×2 = 4"
"Which gas do plants absorb during photosynthesis?","Oxygen","Nitrogen","Carbon Dioxide","Hydrogen","C","Plants absorb CO₂ and release oxygen during photosynthesis"
"What is the capital of Nigeria?","Lagos","Abuja","Kano","Ibadan","B","Abuja replaced Lagos as capital in December 1991"
👨🎓 Student Roster CSV Format Reference
When importing a class roster on the Students page, the CSV must follow this format.
Row 1 is the header and is automatically skipped. Duplicate Student IDs are silently skipped.
Column order: FullName, StudentID, Class
Rules:
- StudentID must be unique per teacher
- Class should match the class set on the exam (e.g. SSS2A)
- One student per row
Example rows:
ADEWALE SAMSON, SS/2024/001, SSS2A
FATIMA IBRAHIM, SS/2024/002, SSS2A
CHUKWUEMEKA OBI, SS/2024/003, SSS2B
NGOZI ADELEKE, SS/2024/004, SSS2A