205 lines
9.5 KiB
PL/PgSQL
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());
|