diff --git a/supabase/migrations/20260807000800_applicants.sql b/supabase/migrations/20260807000800_applicants.sql new file mode 100644 index 0000000..4a84c9a --- /dev/null +++ b/supabase/migrations/20260807000800_applicants.sql @@ -0,0 +1,204 @@ +-- 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());