Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-06-30 20:40:17 +00:00
co-authored by renee-png
parent 4ca9d41165
commit 1fdd309ca8
5 changed files with 9288 additions and 4 deletions
@@ -0,0 +1,402 @@
-- ============ ENUMS ============
CREATE TYPE public.app_role AS ENUM ('admin', 'teacher', 'parent');
CREATE TYPE public.attendance_status AS ENUM ('present', 'absent', 'late', 'excused');
CREATE TYPE public.ledger_kind AS ENUM ('charge', 'payment');
CREATE TYPE public.ledger_category AS ENUM ('tuition', 'late_pickup', 'activity', 'other');
-- ============ PROFILES ============
CREATE TABLE public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
full_name TEXT,
email TEXT,
phone TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
GRANT SELECT, INSERT, UPDATE ON public.profiles TO authenticated;
GRANT ALL ON public.profiles TO service_role;
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
-- ============ USER ROLES ============
CREATE TABLE public.user_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role app_role NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, role)
);
GRANT SELECT ON public.user_roles TO authenticated;
GRANT ALL ON public.user_roles TO service_role;
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
CREATE OR REPLACE FUNCTION public.has_role(_user_id UUID, _role app_role)
RETURNS BOOLEAN
LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = _user_id AND role = _role)
$$;
CREATE OR REPLACE FUNCTION public.current_user_has_role(_role app_role)
RETURNS BOOLEAN
LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = auth.uid() AND role = _role)
$$;
-- ============ CLASSES ============
CREATE TABLE public.classes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
teacher_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
GRANT SELECT, INSERT, UPDATE, DELETE ON public.classes TO authenticated;
GRANT ALL ON public.classes TO service_role;
ALTER TABLE public.classes ENABLE ROW LEVEL SECURITY;
-- ============ STUDENTS ============
CREATE TABLE public.students (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
dob DATE,
class_id UUID REFERENCES public.classes(id) ON DELETE SET NULL,
allergies TEXT,
photo_release BOOLEAN NOT NULL DEFAULT FALSE,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
GRANT SELECT, INSERT, UPDATE, DELETE ON public.students TO authenticated;
GRANT ALL ON public.students TO service_role;
ALTER TABLE public.students ENABLE ROW LEVEL SECURITY;
-- ============ PARENT-STUDENT LINKS ============
CREATE TABLE public.parent_students (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
relationship TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (parent_id, student_id)
);
GRANT SELECT, INSERT, UPDATE, DELETE ON public.parent_students TO authenticated;
GRANT ALL ON public.parent_students TO service_role;
ALTER TABLE public.parent_students ENABLE ROW LEVEL SECURITY;
CREATE OR REPLACE FUNCTION public.is_parent_of(_student UUID)
RETURNS BOOLEAN
LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT EXISTS (SELECT 1 FROM public.parent_students WHERE parent_id = auth.uid() AND student_id = _student)
$$;
CREATE OR REPLACE FUNCTION public.teaches_student(_student UUID)
RETURNS BOOLEAN
LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1 FROM public.students s
JOIN public.classes c ON c.id = s.class_id
WHERE s.id = _student AND c.teacher_id = auth.uid()
)
$$;
-- ============ AUTHORIZED PICKUPS ============
CREATE TABLE public.authorized_pickups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
name TEXT NOT NULL,
phone TEXT,
relationship TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
GRANT SELECT, INSERT, UPDATE, DELETE ON public.authorized_pickups TO authenticated;
GRANT ALL ON public.authorized_pickups TO service_role;
ALTER TABLE public.authorized_pickups ENABLE ROW LEVEL SECURITY;
-- ============ CONTRACTS (admin only) ============
CREATE TABLE public.contracts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
title TEXT,
uploaded_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
GRANT SELECT, INSERT, UPDATE, DELETE ON public.contracts TO authenticated;
GRANT ALL ON public.contracts TO service_role;
ALTER TABLE public.contracts ENABLE ROW LEVEL SECURITY;
-- ============ ATTENDANCE ============
CREATE TABLE public.attendance (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
date DATE NOT NULL,
status attendance_status NOT NULL DEFAULT 'present',
recorded_by UUID REFERENCES auth.users(id),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (student_id, date)
);
GRANT SELECT, INSERT, UPDATE, DELETE ON public.attendance TO authenticated;
GRANT ALL ON public.attendance TO service_role;
ALTER TABLE public.attendance ENABLE ROW LEVEL SECURITY;
-- ============ LEDGER ============
CREATE TABLE public.ledger_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
date DATE NOT NULL DEFAULT CURRENT_DATE,
kind ledger_kind NOT NULL,
category ledger_category NOT NULL DEFAULT 'tuition',
amount_cents INTEGER NOT NULL,
note TEXT,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
GRANT SELECT, INSERT, UPDATE, DELETE ON public.ledger_entries TO authenticated;
GRANT ALL ON public.ledger_entries TO service_role;
ALTER TABLE public.ledger_entries ENABLE ROW LEVEL SECURITY;
-- ============ MESSAGING ============
CREATE TABLE public.message_threads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subject TEXT,
student_id UUID REFERENCES public.students(id) ON DELETE SET NULL,
created_by UUID NOT NULL REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
GRANT SELECT, INSERT, UPDATE, DELETE ON public.message_threads TO authenticated;
GRANT ALL ON public.message_threads TO service_role;
ALTER TABLE public.message_threads ENABLE ROW LEVEL SECURITY;
CREATE TABLE public.thread_participants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NOT NULL REFERENCES public.message_threads(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
UNIQUE (thread_id, user_id)
);
GRANT SELECT, INSERT, UPDATE, DELETE ON public.thread_participants TO authenticated;
GRANT ALL ON public.thread_participants TO service_role;
ALTER TABLE public.thread_participants ENABLE ROW LEVEL SECURITY;
CREATE OR REPLACE FUNCTION public.is_thread_participant(_thread UUID)
RETURNS BOOLEAN
LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT EXISTS (SELECT 1 FROM public.thread_participants WHERE thread_id = _thread AND user_id = auth.uid())
$$;
CREATE TABLE public.messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NOT NULL REFERENCES public.message_threads(id) ON DELETE CASCADE,
sender_id UUID NOT NULL REFERENCES auth.users(id),
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
GRANT SELECT, INSERT ON public.messages TO authenticated;
GRANT ALL ON public.messages TO service_role;
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
-- ============ FORMS ============
CREATE TABLE public.forms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
schema_json JSONB NOT NULL DEFAULT '[]'::jsonb,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
GRANT SELECT, INSERT, UPDATE, DELETE ON public.forms TO authenticated;
GRANT ALL ON public.forms TO service_role;
ALTER TABLE public.forms ENABLE ROW LEVEL SECURITY;
CREATE TABLE public.form_responses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
form_id UUID NOT NULL REFERENCES public.forms(id) ON DELETE CASCADE,
student_id UUID REFERENCES public.students(id) ON DELETE SET NULL,
submitted_by UUID NOT NULL REFERENCES auth.users(id),
data_json JSONB NOT NULL DEFAULT '{}'::jsonb,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
GRANT SELECT, INSERT ON public.form_responses TO authenticated;
GRANT ALL ON public.form_responses TO service_role;
ALTER TABLE public.form_responses ENABLE ROW LEVEL SECURITY;
-- ============ CALENDAR ============
CREATE TABLE public.calendar_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
date DATE NOT NULL,
end_date DATE,
title TEXT NOT NULL,
description TEXT,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
GRANT SELECT, INSERT, UPDATE, DELETE ON public.calendar_events TO authenticated;
GRANT ALL ON public.calendar_events TO service_role;
ALTER TABLE public.calendar_events ENABLE ROW LEVEL SECURITY;
-- ============ POLICIES ============
-- profiles: each user sees their own; admins see all
CREATE POLICY "own profile read" ON public.profiles FOR SELECT TO authenticated
USING (id = auth.uid() OR public.current_user_has_role('admin'));
CREATE POLICY "own profile update" ON public.profiles FOR UPDATE TO authenticated
USING (id = auth.uid() OR public.current_user_has_role('admin'));
CREATE POLICY "own profile insert" ON public.profiles FOR INSERT TO authenticated
WITH CHECK (id = auth.uid());
-- user_roles: users see their own, admins manage all
CREATE POLICY "see own roles" ON public.user_roles FOR SELECT TO authenticated
USING (user_id = auth.uid() OR public.current_user_has_role('admin'));
CREATE POLICY "admins manage roles" ON public.user_roles FOR ALL TO authenticated
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
-- classes
CREATE POLICY "classes read" ON public.classes FOR SELECT TO authenticated
USING (public.current_user_has_role('admin') OR teacher_id = auth.uid() OR public.current_user_has_role('parent'));
CREATE POLICY "admins manage classes" ON public.classes FOR ALL TO authenticated
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
-- students
CREATE POLICY "students read" ON public.students FOR SELECT TO authenticated
USING (
public.current_user_has_role('admin')
OR public.is_parent_of(id)
OR (class_id IS NOT NULL AND EXISTS (SELECT 1 FROM public.classes c WHERE c.id = class_id AND c.teacher_id = auth.uid()))
);
CREATE POLICY "admins manage students" ON public.students FOR ALL TO authenticated
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
-- parent_students
CREATE POLICY "parent_students read" ON public.parent_students FOR SELECT TO authenticated
USING (
public.current_user_has_role('admin')
OR parent_id = auth.uid()
OR public.teaches_student(student_id)
);
CREATE POLICY "admins manage parent_students" ON public.parent_students FOR ALL TO authenticated
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
-- authorized_pickups
CREATE POLICY "pickups read" ON public.authorized_pickups FOR SELECT TO authenticated
USING (
public.current_user_has_role('admin')
OR public.is_parent_of(student_id)
OR public.teaches_student(student_id)
);
CREATE POLICY "pickups write parent or admin" ON public.authorized_pickups FOR ALL TO authenticated
USING (public.current_user_has_role('admin') OR public.is_parent_of(student_id))
WITH CHECK (public.current_user_has_role('admin') OR public.is_parent_of(student_id));
-- contracts: ADMIN ONLY
CREATE POLICY "contracts admin only" ON public.contracts FOR ALL TO authenticated
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
-- attendance
CREATE POLICY "attendance read" ON public.attendance FOR SELECT TO authenticated
USING (
public.current_user_has_role('admin')
OR public.is_parent_of(student_id)
OR public.teaches_student(student_id)
);
CREATE POLICY "attendance write teacher or admin" ON public.attendance FOR ALL TO authenticated
USING (public.current_user_has_role('admin') OR public.teaches_student(student_id))
WITH CHECK (public.current_user_has_role('admin') OR public.teaches_student(student_id));
-- ledger
CREATE POLICY "ledger read" ON public.ledger_entries FOR SELECT TO authenticated
USING (public.current_user_has_role('admin') OR public.is_parent_of(student_id));
CREATE POLICY "ledger admin manage" ON public.ledger_entries FOR ALL TO authenticated
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
-- threads
CREATE POLICY "threads participants read" ON public.message_threads FOR SELECT TO authenticated
USING (public.current_user_has_role('admin') OR public.is_thread_participant(id));
CREATE POLICY "threads create" ON public.message_threads FOR INSERT TO authenticated
WITH CHECK (created_by = auth.uid());
CREATE POLICY "threads admin manage" ON public.message_threads FOR UPDATE TO authenticated
USING (public.current_user_has_role('admin'));
CREATE POLICY "tp read" ON public.thread_participants FOR SELECT TO authenticated
USING (public.current_user_has_role('admin') OR user_id = auth.uid() OR public.is_thread_participant(thread_id));
CREATE POLICY "tp insert" ON public.thread_participants FOR INSERT TO authenticated
WITH CHECK (
public.current_user_has_role('admin')
OR EXISTS (SELECT 1 FROM public.message_threads t WHERE t.id = thread_id AND t.created_by = auth.uid())
);
CREATE POLICY "msg read" ON public.messages FOR SELECT TO authenticated
USING (public.current_user_has_role('admin') OR public.is_thread_participant(thread_id));
CREATE POLICY "msg send" ON public.messages FOR INSERT TO authenticated
WITH CHECK (sender_id = auth.uid() AND (public.current_user_has_role('admin') OR public.is_thread_participant(thread_id)));
-- forms
CREATE POLICY "forms read all" ON public.forms FOR SELECT TO authenticated USING (TRUE);
CREATE POLICY "forms admin manage" ON public.forms FOR ALL TO authenticated
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
CREATE POLICY "form_responses read" ON public.form_responses FOR SELECT TO authenticated
USING (public.current_user_has_role('admin') OR submitted_by = auth.uid());
CREATE POLICY "form_responses submit" ON public.form_responses FOR INSERT TO authenticated
WITH CHECK (submitted_by = auth.uid());
-- calendar
CREATE POLICY "calendar read" ON public.calendar_events FOR SELECT TO authenticated USING (TRUE);
CREATE POLICY "calendar admin manage" ON public.calendar_events FOR ALL TO authenticated
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
-- ============ STORAGE POLICIES (contracts bucket: admin only) ============
CREATE POLICY "contracts bucket admin read" ON storage.objects FOR SELECT TO authenticated
USING (bucket_id = 'contracts' AND public.current_user_has_role('admin'));
CREATE POLICY "contracts bucket admin write" ON storage.objects FOR INSERT TO authenticated
WITH CHECK (bucket_id = 'contracts' AND public.current_user_has_role('admin'));
CREATE POLICY "contracts bucket admin update" ON storage.objects FOR UPDATE TO authenticated
USING (bucket_id = 'contracts' AND public.current_user_has_role('admin'));
CREATE POLICY "contracts bucket admin delete" ON storage.objects FOR DELETE TO authenticated
USING (bucket_id = 'contracts' AND public.current_user_has_role('admin'));
-- ============ AUTO PROFILE + FIRST-USER-ADMIN TRIGGER ============
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
user_count INTEGER;
BEGIN
INSERT INTO public.profiles (id, full_name, email)
VALUES (NEW.id, COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.email), NEW.email)
ON CONFLICT (id) DO NOTHING;
SELECT COUNT(*) INTO user_count FROM public.user_roles;
IF user_count = 0 THEN
INSERT INTO public.user_roles (user_id, role) VALUES (NEW.id, 'admin');
ELSE
INSERT INTO public.user_roles (user_id, role) VALUES (NEW.id, 'parent')
ON CONFLICT (user_id, role) DO NOTHING;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- updated_at trigger function
CREATE OR REPLACE FUNCTION public.set_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$;
CREATE TRIGGER trg_profiles_upd BEFORE UPDATE ON public.profiles FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_students_upd BEFORE UPDATE ON public.students FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_threads_upd BEFORE UPDATE ON public.message_threads FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();