Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / db/migrations/0095_interview_scheduling.sql |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | db/migrations/0095_interview_scheduling.sql |
| Original SHA-256 | e8582e3b94a7ecec520f65d8a82c5e211b3a223531e067bd56d06b2614a64110 |
| Git blob SHA | f5377bc86c38eeab2e463cb7ad60980330cdcfcb |
| Reading-copy SHA-256 | e8582e3b94a7ecec520f65d8a82c5e211b3a223531e067bd56d06b2614a64110 |
Recovered source transcript
Download complete source text ↓138 lines · 8.2 KB
-- +goose Up
-- INTERVIEW SCHEDULING. Offering times, a candidate picking one, and a real calendar invite.
--
-- interviews has existed since migration 0048 and it RECORDS an interview: a time, a kind, an outcome. It
-- has no way to ARRANGE one. Arranging is the part that costs a recruiter an afternoon, because it is four
-- emails and a phone call per candidate and it is the single most rescheduled event in the process.
--
-- THE CALENDAR INVITE IS AN iCALENDAR ATTACHMENT, NOT A CALENDAR INTEGRATION, and that is a deliberate choice
-- rather than a shortcut. Integrating with Google Calendar and Microsoft Graph means two vendor specific
-- OAuth applications, two consent screens, two sets of credentials to hold, and a permanent dependency on
-- two APIs, all of which cuts against the both clouds no lock in mandate. An iCalendar REQUEST attachment is
-- the interoperable standard every one of those calendars already accepts, it needs no credentials at all,
-- and it works for a candidate whose calendar is none of the above.
--
-- AN OFFER IS NOT AN INTERVIEW. Offering three times creates an offer with three slots; the interviews row
-- is created only when somebody picks one. Creating the interview up front and moving it would mean a
-- pipeline full of interviews that never happened.
-- ============================================================================
-- 1. interview_offers
-- ============================================================================
CREATE TABLE interview_offers (
organization_id uuid NOT NULL REFERENCES organizations(id),
id uuid NOT NULL DEFAULT gen_random_uuid(),
application_id uuid NOT NULL,
-- The public booking key. Opaque and globally unique, never the application id, so a link discloses
-- nothing internal and cannot be walked to find other candidates' interviews.
public_token text NOT NULL,
kind text NOT NULL DEFAULT 'client'
CHECK (kind IN ('phone_screen','technical','client','panel','final')),
round int NOT NULL DEFAULT 1 CHECK (round BETWEEN 1 AND 20),
duration_mins int NOT NULL DEFAULT 45 CHECK (duration_mins BETWEEN 5 AND 480),
-- What the candidate is told. Free text because "the third floor reception, ask for Dana" and a video
-- link are the same field to the person reading it.
location_text text NOT NULL DEFAULT '',
interviewer text NOT NULL DEFAULT '',
message text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'open'
CHECK (status IN ('open','booked','expired','withdrawn')),
expires_at timestamptz NOT NULL,
booked_slot_id uuid,
interview_id uuid,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (organization_id, id),
CONSTRAINT uq_interview_offers_token UNIQUE (public_token),
CONSTRAINT uq_interview_offers_org_id UNIQUE (organization_id, id),
CONSTRAINT fk_interview_offers_app FOREIGN KEY (organization_id, application_id)
REFERENCES applications (organization_id, id) ON DELETE CASCADE,
CONSTRAINT fk_interview_offers_creator FOREIGN KEY (organization_id, created_by)
REFERENCES users (organization_id, id) ON DELETE SET NULL,
CONSTRAINT ck_interview_offer_token CHECK (public_token ~ '^[a-z0-9]{16,64}$'),
CONSTRAINT ck_interview_offer_sizes CHECK (
length(location_text) <= 500 AND length(interviewer) <= 200 AND length(message) <= 2000),
-- A booked offer must name what was booked, or the link between the offer and the interview is a claim
-- nobody can check.
CONSTRAINT ck_interview_offer_booked CHECK (
status <> 'booked' OR (booked_slot_id IS NOT NULL AND interview_id IS NOT NULL))
);
CREATE INDEX idx_interview_offers_app ON interview_offers (organization_id, application_id, created_at DESC);
CREATE INDEX idx_interview_offers_open ON interview_offers (organization_id, status, expires_at)
WHERE status = 'open';
ALTER TABLE interview_offers ENABLE ROW LEVEL SECURITY;
ALTER TABLE interview_offers FORCE ROW LEVEL SECURITY;
CREATE POLICY interview_offers_isolation ON interview_offers
USING (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid)
WITH CHECK (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid);
-- NO DELETE. What times a candidate was offered and when is part of the record of how they were treated,
-- which under an Automated Employment Decision Tool is exactly the kind of thing an audit asks about.
GRANT SELECT, INSERT, UPDATE ON interview_offers TO missionmatch_app;
-- ============================================================================
-- 2. interview_offer_slots
-- ============================================================================
CREATE TABLE interview_offer_slots (
organization_id uuid NOT NULL REFERENCES organizations(id),
id uuid NOT NULL DEFAULT gen_random_uuid(),
offer_id uuid NOT NULL,
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
-- A slot a candidate can no longer take, because somebody else booked the interviewer or the recruiter
-- withdrew it. Kept rather than deleted so the offer shows what was actually available at the time.
available boolean NOT NULL DEFAULT true,
PRIMARY KEY (organization_id, id),
CONSTRAINT uq_offer_slots_org_id UNIQUE (organization_id, id),
CONSTRAINT fk_offer_slots_offer FOREIGN KEY (organization_id, offer_id)
REFERENCES interview_offers (organization_id, id) ON DELETE CASCADE,
CONSTRAINT ck_offer_slot_window CHECK (ends_at > starts_at)
);
CREATE INDEX idx_offer_slots_offer ON interview_offer_slots (organization_id, offer_id, starts_at);
ALTER TABLE interview_offer_slots ENABLE ROW LEVEL SECURITY;
ALTER TABLE interview_offer_slots FORCE ROW LEVEL SECURITY;
CREATE POLICY interview_offer_slots_isolation ON interview_offer_slots
USING (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid)
WITH CHECK (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE ON interview_offer_slots TO missionmatch_app;
ALTER TABLE interview_offers ADD CONSTRAINT fk_interview_offers_slot
FOREIGN KEY (organization_id, booked_slot_id)
REFERENCES interview_offer_slots (organization_id, id) ON DELETE SET NULL;
-- ============================================================================
-- 3. the public seam
-- ============================================================================
-- The same shape as the careers surface: a public request has no session, so a SECURITY DEFINER function is
-- the only way across the line, and it answers only for an OPEN offer that has not expired. It returns what
-- a candidate needs to choose a time and nothing about the pipeline they are in.
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION public_interview_offer(p_token text)
RETURNS TABLE (
organization_id uuid, offer_id uuid, kind text, duration_mins int, location_text text,
interviewer text, message text, expires_at timestamptz, candidate_name text,
requisition_title text, organization_name text
) AS $$
SELECT o.organization_id, o.id, o.kind, o.duration_mins, o.location_text, o.interviewer, o.message,
o.expires_at, c.full_name, r.title, org.name
FROM interview_offers o
JOIN applications a ON a.id = o.application_id
JOIN candidates c ON c.id = a.candidate_id
JOIN requisitions r ON r.id = a.requisition_id
JOIN organizations org ON org.id = o.organization_id
WHERE o.public_token = p_token
AND o.status = 'open'
AND o.expires_at > now();
$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp;
-- +goose StatementEnd
REVOKE ALL ON FUNCTION public_interview_offer(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public_interview_offer(text) TO missionmatch_app;
SELECT mm_mirror_staging_role_privileges();
-- +goose Down
DROP FUNCTION IF EXISTS public_interview_offer(text);
ALTER TABLE interview_offers DROP CONSTRAINT IF EXISTS fk_interview_offers_slot;
DROP TABLE IF EXISTS interview_offer_slots;
DROP TABLE IF EXISTS interview_offers;