Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / db/migrations/0091_job_postings_and_inbound.sql |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | db/migrations/0091_job_postings_and_inbound.sql |
| Original SHA-256 | 70033be770cb88ff760b106a8d6590a2f6ec9d3dd11a474068c0a2d02b8de230 |
| Git blob SHA | 95a0909c659291fcb7ebb4682be253bb2da5606a |
| Reading-copy SHA-256 | 70033be770cb88ff760b106a8d6590a2f6ec9d3dd11a474068c0a2d02b8de230 |
Recovered source transcript
Download complete source text ↓220 lines · 13.8 KB
-- +goose Up
-- THE INBOUND PATH. A public job posting and a way for a person to apply to it.
--
-- Until now every candidate had to be imported or typed in by a recruiter. There was no careers page, no
-- posting, and no apply route, which means the product could only ever hold people somebody had already
-- found. That is the difference between a database and a pipeline.
--
-- TWO PROBLEMS MAKE THIS DIFFERENT FROM EVERY OTHER SURFACE HERE, and both shape the schema.
--
-- ONE. A PUBLIC REQUEST HAS NO SESSION, so it has no organization, so row level security correctly denies it
-- everything. The resolution is a SECURITY DEFINER function that turns an opaque public token into an
-- organization id, and nothing else. The application role cannot read job_postings directly across tenants;
-- it asks the function, and the function answers only for a posting that is actually PUBLISHED. That is the
-- same shape provision_org already uses and it keeps the app role NOBYPASSRLS.
--
-- TWO. AN INBOUND SUBMISSION IS NOT A CANDIDATE YET, and conflating them would be both a compliance mistake
-- and an abuse hole. Under the OFCCP Internet Applicant rule an applicant is somebody who expressed interest
-- AND whom the contractor then CONSIDERED for a particular position. Those are two separate events and a
-- federal contractor has to be able to show both. So a submission lands in job_applications as what the
-- person actually sent, and a recruiter converting it into a candidate is a distinct, recorded act. It also
-- means spam never becomes a candidate row.
-- ============================================================================
-- 1. job_postings
-- ============================================================================
CREATE TABLE job_postings (
organization_id uuid NOT NULL REFERENCES organizations(id),
id uuid NOT NULL DEFAULT gen_random_uuid(),
requisition_id uuid NOT NULL,
-- The URL key. Opaque and globally unique, never the requisition id, so a public link discloses nothing
-- about internal identifiers and cannot be walked to find other postings.
public_token text NOT NULL,
-- THE PUBLIC COPY IS ITS OWN TEXT, NOT A JOIN TO THE REQUISITION, and this is the load bearing decision
-- in this table. A requisition carries bill rate, pay rate, the client name and the internal description.
-- Rendering a public page from it would put the agency's margin and its customer's identity on the open
-- internet, and the failure mode is silent: nobody notices until a competitor reads it. Publishing is
-- therefore an act of WRITING a public description, and a field that is not on this row cannot leak.
title text NOT NULL,
summary text NOT NULL DEFAULT '',
responsibilities text NOT NULL DEFAULT '',
qualifications text NOT NULL DEFAULT '',
location_text text NOT NULL DEFAULT '',
remote_allowed boolean NOT NULL DEFAULT false,
employment_type text NOT NULL DEFAULT 'contract',
-- The clearance a reader is told about. It is DISPLAY TEXT, deliberately, not a foreign key to the code
-- table, because what a posting says publicly ("TS/SCI with polygraph preferred") is a sentence and not
-- an enum, and forcing it through the enum would either flatten the meaning or leak the internal code.
clearance_text text NOT NULL DEFAULT '',
-- Pay may be published, and in several jurisdictions must be. It is a SEPARATE public field rather than
-- the requisition's rate, so publishing a range is a deliberate act and never an accident of a join.
pay_range_text text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'closed')),
published_at timestamptz,
closes_at timestamptz,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (organization_id, id),
CONSTRAINT uq_job_postings_token UNIQUE (public_token),
CONSTRAINT uq_job_postings_org_id UNIQUE (organization_id, id),
CONSTRAINT fk_job_postings_req FOREIGN KEY (organization_id, requisition_id)
REFERENCES requisitions (organization_id, id) ON DELETE CASCADE,
CONSTRAINT fk_job_postings_creator FOREIGN KEY (organization_id, created_by)
REFERENCES users (organization_id, id) ON DELETE SET NULL,
CONSTRAINT ck_job_posting_title CHECK (length(btrim(title)) BETWEEN 1 AND 300),
CONSTRAINT ck_job_posting_token CHECK (public_token ~ '^[a-z0-9]{16,64}$'),
-- A published posting must have been published at a time. Without this a row can claim to be live with
-- no record of when it went live, which is the first question in any hiring audit.
CONSTRAINT ck_job_posting_published CHECK (status <> 'published' OR published_at IS NOT NULL)
);
CREATE INDEX idx_job_postings_org_status ON job_postings (organization_id, status, published_at DESC);
ALTER TABLE job_postings ENABLE ROW LEVEL SECURITY;
ALTER TABLE job_postings FORCE ROW LEVEL SECURITY;
CREATE POLICY job_postings_isolation ON job_postings
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. A posting is CLOSED. What a job advertisement said, and when, is exactly the record an OFCCP
-- audit asks for, and a posting that can be deleted is a posting that cannot be produced.
GRANT SELECT, INSERT, UPDATE ON job_postings TO missionmatch_app;
-- ============================================================================
-- 2. job_applications, what a person actually sent
-- ============================================================================
CREATE TABLE job_applications (
organization_id uuid NOT NULL REFERENCES organizations(id),
id uuid NOT NULL DEFAULT gen_random_uuid(),
posting_id uuid NOT NULL,
-- Verbatim, as submitted. These are NOT normalised into candidate fields, because the compliance
-- question is what the person said, not what the system made of it.
full_name text NOT NULL,
email text NOT NULL,
phone text NOT NULL DEFAULT '',
location_text text NOT NULL DEFAULT '',
cover_note text NOT NULL DEFAULT '',
-- Self reported and shown as an INDICATOR, never as verified. The real check is the hiring client's FSO
-- through DISS at onboarding, which is the rule stated wherever clearance appears in this product.
clearance_self_reported text NOT NULL DEFAULT '',
-- The resume, if one was attached. Stored the same way every other document is.
resume_storage_key text NOT NULL DEFAULT '',
resume_file_name text NOT NULL DEFAULT '',
resume_media_type text NOT NULL DEFAULT '',
resume_sha256 text NOT NULL DEFAULT '',
-- THE COMPLIANCE HALF. Under the Internet Applicant rule an applicant is somebody who expressed interest
-- AND was then considered for the position. These two columns record the second event separately from
-- the first, so a contractor can show which submissions became considerations and which did not, and
-- why.
status text NOT NULL DEFAULT 'received'
CHECK (status IN ('received','considered','converted','rejected','withdrawn','spam')),
status_reason text NOT NULL DEFAULT '',
reviewed_by uuid,
reviewed_at timestamptz,
-- Set when a recruiter turns this submission into a real candidate. Nullable, because most submissions
-- never become one and pretending otherwise would inflate the bench with people nobody looked at.
candidate_id uuid,
-- Where the submission came from, so source reporting is possible. Free text because a job board's name
-- is not a closed set, bounded so it cannot become a payload.
source text NOT NULL DEFAULT 'careers_page',
-- The consent the person gave at submission time, recorded as text and a timestamp rather than a bare
-- boolean, because "they ticked a box" is not a defensible answer and the wording changes over time.
consent_text text NOT NULL DEFAULT '',
consented_at timestamptz,
submitted_ip inet,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (organization_id, id),
CONSTRAINT fk_job_apps_posting FOREIGN KEY (organization_id, posting_id)
REFERENCES job_postings (organization_id, id) ON DELETE CASCADE,
CONSTRAINT fk_job_apps_candidate FOREIGN KEY (organization_id, candidate_id)
REFERENCES candidates (organization_id, id) ON DELETE SET NULL,
CONSTRAINT fk_job_apps_reviewer FOREIGN KEY (organization_id, reviewed_by)
REFERENCES users (organization_id, id) ON DELETE SET NULL,
CONSTRAINT ck_job_app_name CHECK (length(btrim(full_name)) BETWEEN 1 AND 200),
CONSTRAINT ck_job_app_email CHECK (length(email) BETWEEN 6 AND 320 AND position('@' in email) > 1),
CONSTRAINT ck_job_app_sizes CHECK (
length(cover_note) <= 5000 AND length(source) <= 120 AND length(consent_text) <= 2000),
-- A converted submission must name the candidate it became, or the link between the application and the
-- person is a claim nobody can check.
CONSTRAINT ck_job_app_converted CHECK (status <> 'converted' OR candidate_id IS NOT NULL)
);
CREATE INDEX idx_job_apps_posting ON job_applications (organization_id, posting_id, created_at DESC);
CREATE INDEX idx_job_apps_status ON job_applications (organization_id, status, created_at DESC);
-- One person may not flood one posting with the same address. A partial unique on the live statuses, so a
-- rejected applicant can legitimately apply again to a later posting and a re application to the SAME posting
-- is refused politely rather than silently duplicated.
CREATE UNIQUE INDEX uq_job_apps_once ON job_applications (organization_id, posting_id, lower(email))
WHERE status IN ('received', 'considered', 'converted');
ALTER TABLE job_applications ENABLE ROW LEVEL SECURITY;
ALTER TABLE job_applications FORCE ROW LEVEL SECURITY;
CREATE POLICY job_applications_isolation ON job_applications
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. An applicant flow log with holes in it is not an applicant flow log. Spam is marked spam.
GRANT SELECT, INSERT, UPDATE ON job_applications TO missionmatch_app;
-- ============================================================================
-- 3. the public seam
-- ============================================================================
-- A public request carries no session, so it has no organization, so RLS denies it everything. These two
-- SECURITY DEFINER functions are the ONLY way across that line, and they are deliberately narrow: they answer
-- for PUBLISHED postings only, they return the public copy and nothing from the requisition, and they take a
-- token or a slug rather than an organization id so a caller can never name a tenant.
--
-- The app role gets EXECUTE. It still cannot SELECT job_postings for another tenant, because the policy above
-- is unchanged; it can only ask these questions.
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION public_posting_by_token(p_token text)
RETURNS TABLE (
organization_id uuid, posting_id uuid, title text, summary text, responsibilities text,
qualifications text, location_text text, remote_allowed boolean, employment_type text,
clearance_text text, pay_range_text text, published_at timestamptz, closes_at timestamptz,
organization_name text
) AS $$
SELECT p.organization_id, p.id, p.title, p.summary, p.responsibilities, p.qualifications,
p.location_text, p.remote_allowed, p.employment_type, p.clearance_text, p.pay_range_text,
p.published_at, p.closes_at, o.name
FROM job_postings p
JOIN organizations o ON o.id = p.organization_id
WHERE p.public_token = p_token
AND p.status = 'published'
-- A closed date in the past means the posting is gone from the public internet even if somebody forgot
-- to close it by hand, which is what stops a stale advertisement collecting applications for a role that
-- was filled in March.
AND (p.closes_at IS NULL OR p.closes_at > now());
$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp;
-- +goose StatementEnd
REVOKE ALL ON FUNCTION public_posting_by_token(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public_posting_by_token(text) TO missionmatch_app;
-- +goose StatementBegin
-- The careers page: every live posting for one organization, found by its slug.
CREATE OR REPLACE FUNCTION public_postings_by_slug(p_slug text)
RETURNS TABLE (
organization_id uuid, posting_id uuid, public_token text, title text, summary text,
location_text text, remote_allowed boolean, employment_type text, clearance_text text,
published_at timestamptz, organization_name text
) AS $$
SELECT p.organization_id, p.id, p.public_token, p.title, p.summary, p.location_text,
p.remote_allowed, p.employment_type, p.clearance_text, p.published_at, o.name
FROM job_postings p
JOIN organizations o ON o.id = p.organization_id
WHERE lower(o.slug) = lower(p_slug)
AND o.status = 'active'
AND p.status = 'published'
AND (p.closes_at IS NULL OR p.closes_at > now())
ORDER BY p.published_at DESC
LIMIT 200;
$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp;
-- +goose StatementEnd
REVOKE ALL ON FUNCTION public_postings_by_slug(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public_postings_by_slug(text) TO missionmatch_app;
SELECT mm_mirror_staging_role_privileges();
-- +goose Down
DROP FUNCTION IF EXISTS public_postings_by_slug(text);
DROP FUNCTION IF EXISTS public_posting_by_token(text);
DROP TABLE IF EXISTS job_applications;
DROP TABLE IF EXISTS job_postings;