-- Candidate and applicant tracking — spec section 12. -- -- Section 12 sits between staff compliance (11) and administrative calendars -- (13), and the spec's own role list treats employee records as confidential -- management data. This is therefore modelled as hiring: candidates for staff -- positions, moving through a configurable pipeline. -- -- Student enrolment applications are already served by the intake_tokens / -- forms path from the earlier migrations, so they are not duplicated here. CREATE TABLE IF NOT EXISTS public.applicant_stages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE, sort_order INTEGER NOT NULL DEFAULT 100, is_terminal BOOLEAN NOT NULL DEFAULT FALSE, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); GRANT SELECT, INSERT, UPDATE, DELETE ON public.applicant_stages TO authenticated; GRANT ALL ON public.applicant_stages TO service_role; ALTER TABLE public.applicant_stages ENABLE ROW LEVEL SECURITY; INSERT INTO public.applicant_stages (name, slug, sort_order, is_terminal) VALUES ('Applied', 'applied', 10, FALSE), ('Screening', 'screening', 20, FALSE), ('Interview', 'interview', 30, FALSE), ('Reference check', 'reference_check', 40, FALSE), ('Background check', 'background_check', 50, FALSE), ('Offer', 'offer', 60, FALSE), ('Hired', 'hired', 70, TRUE), ('Declined', 'declined', 80, TRUE), ('Withdrawn', 'withdrawn', 90, TRUE) ON CONFLICT (slug) DO NOTHING; CREATE TABLE IF NOT EXISTS public.applicants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), first_name TEXT NOT NULL, last_name TEXT NOT NULL, email TEXT, phone TEXT, position_applied TEXT NOT NULL, campus_id UUID REFERENCES public.campuses(id) ON DELETE SET NULL, stage_id UUID REFERENCES public.applicant_stages(id) ON DELETE SET NULL, source TEXT, applied_on DATE NOT NULL DEFAULT CURRENT_DATE, resume_path TEXT, desired_start_date DATE, desired_pay_cents INTEGER, -- Set when a candidate is hired and becomes a user, linking the two records. hired_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, hired_on DATE, rating SMALLINT CHECK (rating IS NULL OR (rating BETWEEN 1 AND 5)), is_active BOOLEAN NOT NULL DEFAULT TRUE, created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); GRANT SELECT, INSERT, UPDATE, DELETE ON public.applicants TO authenticated; GRANT ALL ON public.applicants TO service_role; ALTER TABLE public.applicants ENABLE ROW LEVEL SECURITY; CREATE INDEX IF NOT EXISTS app_stage_idx ON public.applicants (stage_id); CREATE INDEX IF NOT EXISTS app_campus_idx ON public.applicants (campus_id); CREATE INDEX IF NOT EXISTS app_hired_user_idx ON public.applicants (hired_user_id); CREATE INDEX IF NOT EXISTS app_open_idx ON public.applicants (applied_on DESC) WHERE is_active; DROP TRIGGER IF EXISTS trg_app_upd ON public.applicants; CREATE TRIGGER trg_app_upd BEFORE UPDATE ON public.applicants FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); -- Stage history, so "how long did this sit in screening" is answerable. CREATE TABLE IF NOT EXISTS public.applicant_stage_history ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), applicant_id UUID NOT NULL REFERENCES public.applicants(id) ON DELETE CASCADE, from_stage_id UUID REFERENCES public.applicant_stages(id) ON DELETE SET NULL, to_stage_id UUID REFERENCES public.applicant_stages(id) ON DELETE SET NULL, changed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now(), note TEXT ); GRANT SELECT, INSERT ON public.applicant_stage_history TO authenticated; GRANT ALL ON public.applicant_stage_history TO service_role; ALTER TABLE public.applicant_stage_history ENABLE ROW LEVEL SECURITY; CREATE INDEX IF NOT EXISTS ash_applicant_idx ON public.applicant_stage_history (applicant_id, changed_at DESC); CREATE INDEX IF NOT EXISTS ash_from_idx ON public.applicant_stage_history (from_stage_id); CREATE INDEX IF NOT EXISTS ash_to_idx ON public.applicant_stage_history (to_stage_id); CREATE OR REPLACE FUNCTION public.record_applicant_stage_change() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ BEGIN IF TG_OP = 'UPDATE' AND NEW.stage_id IS DISTINCT FROM OLD.stage_id THEN INSERT INTO public.applicant_stage_history (applicant_id, from_stage_id, to_stage_id, changed_by) VALUES (NEW.id, OLD.stage_id, NEW.stage_id, (SELECT auth.uid())); END IF; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS trg_app_stage_history ON public.applicants; CREATE TRIGGER trg_app_stage_history AFTER UPDATE ON public.applicants FOR EACH ROW EXECUTE FUNCTION public.record_applicant_stage_change(); CREATE TABLE IF NOT EXISTS public.applicant_notes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), applicant_id UUID NOT NULL REFERENCES public.applicants(id) ON DELETE CASCADE, author_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, body TEXT NOT NULL, is_confidential BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); GRANT SELECT, INSERT, UPDATE, DELETE ON public.applicant_notes TO authenticated; GRANT ALL ON public.applicant_notes TO service_role; ALTER TABLE public.applicant_notes ENABLE ROW LEVEL SECURITY; CREATE INDEX IF NOT EXISTS an_applicant_idx ON public.applicant_notes (applicant_id, created_at DESC); CREATE INDEX IF NOT EXISTS an_author_idx ON public.applicant_notes (author_id); CREATE TABLE IF NOT EXISTS public.applicant_documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), applicant_id UUID NOT NULL REFERENCES public.applicants(id) ON DELETE CASCADE, file_path TEXT NOT NULL, title TEXT, document_type TEXT, uploaded_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); GRANT SELECT, INSERT, UPDATE, DELETE ON public.applicant_documents TO authenticated; GRANT ALL ON public.applicant_documents TO service_role; ALTER TABLE public.applicant_documents ENABLE ROW LEVEL SECURITY; CREATE INDEX IF NOT EXISTS ad_applicant_idx ON public.applicant_documents (applicant_id); -- ============================================================================ -- POLICIES -- ============================================================================ -- Hiring data is management-only throughout. Campus admins see candidates for -- their own campus; teachers and staff see nothing. DROP POLICY IF EXISTS "applicant stages read" ON public.applicant_stages; CREATE POLICY "applicant stages read" ON public.applicant_stages FOR SELECT TO authenticated USING (public.is_management() OR public.current_user_has_any_role(ARRAY['campus_admin']::app_role[])); DROP POLICY IF EXISTS "applicant stages manage" ON public.applicant_stages; CREATE POLICY "applicant stages manage" ON public.applicant_stages FOR ALL TO authenticated USING (public.is_org_admin()) WITH CHECK (public.is_org_admin()); DROP POLICY IF EXISTS "applicants read" ON public.applicants; CREATE POLICY "applicants read" ON public.applicants FOR SELECT TO authenticated USING ( public.is_management() OR public.is_auditor() OR (public.current_user_has_any_role(ARRAY['campus_admin']::app_role[]) AND campus_id IN (SELECT public.user_campus_ids())) ); DROP POLICY IF EXISTS "applicants manage" ON public.applicants; CREATE POLICY "applicants manage" ON public.applicants FOR ALL TO authenticated USING ( public.is_management() OR (public.current_user_has_any_role(ARRAY['campus_admin']::app_role[]) AND campus_id IN (SELECT public.user_campus_ids())) ) WITH CHECK ( public.is_management() OR (public.current_user_has_any_role(ARRAY['campus_admin']::app_role[]) AND campus_id IN (SELECT public.user_campus_ids())) ); DROP POLICY IF EXISTS "applicant history read" ON public.applicant_stage_history; CREATE POLICY "applicant history read" ON public.applicant_stage_history FOR SELECT TO authenticated USING (EXISTS (SELECT 1 FROM public.applicants a WHERE a.id = applicant_id)); DROP POLICY IF EXISTS "applicant history insert" ON public.applicant_stage_history; CREATE POLICY "applicant history insert" ON public.applicant_stage_history FOR INSERT TO authenticated WITH CHECK (public.is_management()); -- Confidential notes stay with management even when a campus admin can -- otherwise see the candidate. DROP POLICY IF EXISTS "applicant notes read" ON public.applicant_notes; CREATE POLICY "applicant notes read" ON public.applicant_notes FOR SELECT TO authenticated USING ( public.is_management() OR (NOT is_confidential AND EXISTS (SELECT 1 FROM public.applicants a WHERE a.id = applicant_id)) ); DROP POLICY IF EXISTS "applicant notes write" ON public.applicant_notes; CREATE POLICY "applicant notes write" ON public.applicant_notes FOR ALL TO authenticated USING (public.is_management() OR author_id = (SELECT auth.uid())) WITH CHECK (public.is_management() OR author_id = (SELECT auth.uid())); DROP POLICY IF EXISTS "applicant docs read" ON public.applicant_documents; CREATE POLICY "applicant docs read" ON public.applicant_documents FOR SELECT TO authenticated USING (EXISTS (SELECT 1 FROM public.applicants a WHERE a.id = applicant_id)); DROP POLICY IF EXISTS "applicant docs manage" ON public.applicant_documents; CREATE POLICY "applicant docs manage" ON public.applicant_documents FOR ALL TO authenticated USING (public.is_management()) WITH CHECK (public.is_management());