HR-ATS-Portal/backend/migrations/manual/002_backfill_inbox_ats.sql

166 lines
5.7 KiB
SQL

-- 002_backfill_inbox_ats.sql
-- Manual one-shot: backfill the inbox-side ATS columns that gained a writer in
-- job/candidate/views.py::CandidateScoring._sync_inbox_ats. Before that change,
-- inbox_messages.ats_score / ats_band were never written and ats_results held
-- 0 rows, so messages scored earlier show null on the Applications tab.
--
-- Idempotent: the UPDATE only touches rows still missing a score, and the
-- INSERT skips any application that already has an ats_results row.
-- Applied automatically at startup by alembic_setup.run_manual_sql() once the
-- schema is at head; recorded in manual_migrations. Safe to re-run by hand.
--
-- The Candidates.inbox_message_id join is historical: that column is dropped
-- after revision b7d4e8f1a203. Those three statements run only when the column
-- is still present (a DB that has not yet reached that revision). Fresh installs
-- skip them — there is no pre-denorm history to copy.
DO $backfill$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'candidates'
AND column_name = 'inbox_message_id'
) THEN
-- Winning score per message, mirroring CandidateView._recommendation precedence:
-- the completed score against the ASSIGNED job wins, else the newest completed.
EXECUTE $sql$
WITH ranked AS (
SELECT
c.inbox_message_id,
c.job_id,
c.match_score,
c.model,
c.updated_at,
ROW_NUMBER() OVER (
PARTITION BY c.inbox_message_id
ORDER BY (c.job_id = m.assigned_job_post_id) DESC NULLS LAST,
c.updated_at DESC
) AS rn
FROM app.candidates c
JOIN app.inbox_messages m ON m.id = c.inbox_message_id
WHERE c.status = 'completed'
AND c.inbox_message_id IS NOT NULL
)
UPDATE app.inbox_messages m
SET ats_score = r.match_score,
ats_band = CASE
WHEN r.match_score >= 82 THEN 'Strong Match'
WHEN r.match_score >= 65 THEN 'Potential Match'
ELSE 'Weak Match'
END
FROM ranked r
WHERE r.rn = 1
AND m.id = r.inbox_message_id
AND m.ats_score IS NULL
$sql$;
-- One current ats_results row per already-scored application. computed_at takes
-- the candidates row's timestamp so the history reflects when the score happened.
-- inbox can hold several join rows per message; DISTINCT ON keeps the newest.
EXECUTE $sql$
WITH ranked AS (
SELECT
c.inbox_message_id,
c.job_id,
c.match_score,
c.model,
c.updated_at,
ROW_NUMBER() OVER (
PARTITION BY c.inbox_message_id
ORDER BY (c.job_id = m.assigned_job_post_id) DESC NULLS LAST,
c.updated_at DESC
) AS rn
FROM app.candidates c
JOIN app.inbox_messages m ON m.id = c.inbox_message_id
WHERE c.status = 'completed'
AND c.inbox_message_id IS NOT NULL
),
links AS (
SELECT DISTINCT ON (message_id) message_id, id AS inbox_id
FROM app.inbox
ORDER BY message_id, created_at DESC
)
INSERT INTO app.ats_results
(id, inbox_id, job_post_id, overall_score, band, is_current,
superseded_by_id, model_name, computed_at, created_at)
SELECT
gen_random_uuid(),
l.inbox_id,
r.job_id,
r.match_score,
CASE
WHEN r.match_score >= 82 THEN 'Strong Match'
WHEN r.match_score >= 65 THEN 'Potential Match'
ELSE 'Weak Match'
END,
true,
NULL,
r.model,
r.updated_at,
NOW()
FROM ranked r
JOIN links l ON l.message_id = r.inbox_message_id
WHERE r.rn = 1
AND NOT EXISTS (
SELECT 1 FROM app.ats_results a WHERE a.inbox_id = l.inbox_id
)
$sql$;
-- Older inbox history rows predate candidate_id; link the current ones back to
-- the candidates row that produced them.
EXECUTE $sql$
UPDATE app.ats_results a
SET candidate_id = c.id
FROM app.inbox i
JOIN app.candidates c ON c.inbox_message_id = i.message_id AND c.status = 'completed'
WHERE a.inbox_id = i.id
AND a.candidate_id IS NULL
AND c.job_id = a.job_post_id
$sql$;
END IF;
END
$backfill$;
-- inbox.ats_id -> the CURRENT ats_results row for that application, so the
-- score is one direct id join away. Newest current row wins if several exist.
UPDATE app.inbox i
SET ats_id = a.id,
updated_at = NOW()
FROM (
SELECT DISTINCT ON (inbox_id) inbox_id, id
FROM app.ats_results
WHERE is_current AND inbox_id IS NOT NULL
ORDER BY inbox_id, computed_at DESC
) a
WHERE a.inbox_id = i.id
AND i.ats_id IS DISTINCT FROM a.id;
-- Upload-sourced scores get history rows too: inbox_id stays NULL (no inbox
-- application exists) and the row links via candidate_id instead.
INSERT INTO app.ats_results
(id, inbox_id, candidate_id, job_post_id, overall_score, band, is_current,
superseded_by_id, model_name, computed_at, created_at)
SELECT
gen_random_uuid(),
NULL,
c.id,
c.job_id,
c.match_score,
CASE
WHEN c.match_score >= 82 THEN 'Strong Match'
WHEN c.match_score >= 65 THEN 'Potential Match'
ELSE 'Weak Match'
END,
true,
NULL,
c.model,
c.updated_at,
NOW()
FROM app.candidates c
WHERE c.status = 'completed'
AND c.source = 'upload'
AND NOT EXISTS (
SELECT 1 FROM app.ats_results a WHERE a.candidate_id = c.id
);