Files
info-share-spot/supabase/migrations/20260807000800_applicants.sql
T
2026-08-07 04:44:10 +00:00

205 lines
9.5 KiB
PL/PgSQL

-- 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());