Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / db/migrations/0090_saved_searches_and_lists.sql |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | db/migrations/0090_saved_searches_and_lists.sql |
| Original SHA-256 | 12bf23d1ee5a9d6b0163fe59e119cb759d353cfd146c3514f79ce876267fbc7e |
| Git blob SHA | 34d81ab8dc0ba35a388af1a7d7f13b873ca9b6c8 |
| Reading-copy SHA-256 | 12bf23d1ee5a9d6b0163fe59e119cb759d353cfd146c3514f79ce876267fbc7e |
Recovered source transcript
Download complete source text ↓154 lines · 10.0 KB
-- +goose Up
-- SEARCH, SAVED SEARCHES, AND LISTS. The recruiter's actual working surface.
--
-- The gap was worse than the research suggested. There were no saved searches, and there was no SEARCH: the
-- candidates route took a limit and an offset and nothing else, so "TS/SCI cleared network engineers in
-- Maryland who are available" was not a question the product could be asked. The indexes to answer it have
-- existed since migration 0002, a GIN index over search_tsv and an HNSW index over resume_embedding, and
-- nothing was mounted on top of them.
--
-- So this migration adds the two things a search becomes useful WITH, and the search itself is built on the
-- indexes that were already there.
--
-- saved_searches a named set of filters, re run on demand. The question you ask every morning.
-- candidate_lists a named STATIC set of people. A shortlist, a bench for a bid, a do not contact roster.
--
-- THE TWO ARE DIFFERENT AND BOTH ARE NEEDED, which is why this is not one table with a flag. A saved search
-- is a QUESTION and its answer changes as the bench changes; a list is an ANSWER somebody committed to and it
-- must not change underneath them. An agency that shortlists nine people for a bid needs those nine to still
-- be those nine next week.
-- ============================================================================
-- 1. saved_searches
-- ============================================================================
-- The criteria are JSONB rather than typed columns, and that is the opposite of the choice made for
-- org_settings in migration 0087. The reasoning differs because the risk differs. A setting decides who may
-- see pay data, so a key value bag would make the wrong setting a one line insert; a saved search is a
-- person's own query with no security semantics, and the filter vocabulary genuinely grows. What protects
-- this is that the DATABASE NEVER INTERPRETS THE JSON: it is validated back into a typed struct in Go on
-- every execution and every field becomes a bound parameter, so a stored criteria blob is never a query.
CREATE TABLE saved_searches (
organization_id uuid NOT NULL REFERENCES organizations(id),
id uuid NOT NULL DEFAULT gen_random_uuid(),
name text NOT NULL,
-- owner_user_id is who it belongs to. A PRIVATE search is visible only to them; a SHARED one is visible
-- to the organization. Shared is not the default, because a half finished search somebody was
-- experimenting with should not appear on a colleague's screen.
owner_user_id uuid NOT NULL,
visibility text NOT NULL DEFAULT 'private' CHECK (visibility IN ('private', 'shared')),
criteria jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Pinning is what makes this a working surface rather than a filing cabinet: the three searches a
-- recruiter runs every morning sit at the top and the other forty do not.
pinned boolean NOT NULL DEFAULT false,
last_run_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (organization_id, id),
CONSTRAINT fk_saved_searches_owner FOREIGN KEY (organization_id, owner_user_id)
REFERENCES users (organization_id, id) ON DELETE CASCADE,
CONSTRAINT ck_saved_search_name CHECK (length(btrim(name)) BETWEEN 1 AND 120),
-- A criteria blob is bounded so a saved search cannot become a place to store arbitrary data.
CONSTRAINT ck_saved_search_size CHECK (length(criteria::text) <= 8000)
);
-- One person may not have two searches by the same name. Two people may.
CREATE UNIQUE INDEX uq_saved_searches_name ON saved_searches (organization_id, owner_user_id, lower(btrim(name)));
CREATE INDEX idx_saved_searches_visible ON saved_searches (organization_id, visibility, pinned DESC, lower(name));
ALTER TABLE saved_searches ENABLE ROW LEVEL SECURITY;
ALTER TABLE saved_searches FORCE ROW LEVEL SECURITY;
CREATE POLICY saved_searches_isolation ON saved_searches
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);
-- DELETE is granted. A saved search is a person's own working tool, not a historical claim about anybody, so
-- deleting one destroys no evidence. This is the same reasoning recorded for team_members.
GRANT SELECT, INSERT, UPDATE, DELETE ON saved_searches TO missionmatch_app;
-- ============================================================================
-- 2. candidate_lists
-- ============================================================================
CREATE TABLE candidate_lists (
organization_id uuid NOT NULL REFERENCES organizations(id),
id uuid NOT NULL DEFAULT gen_random_uuid(),
name text NOT NULL,
description text NOT NULL DEFAULT '',
owner_user_id uuid NOT NULL,
visibility text NOT NULL DEFAULT 'shared' CHECK (visibility IN ('private', 'shared')),
-- purpose is a label, and it matters for one value in particular. A do_not_contact list is a compliance
-- artefact: a person who asked not to be approached again. Naming the purpose means the interface can
-- treat that list differently from a shortlist rather than trusting somebody to read the title.
purpose text NOT NULL DEFAULT 'shortlist'
CHECK (purpose IN ('shortlist','bench','pipeline','do_not_contact','other')),
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (organization_id, id),
CONSTRAINT uq_candidate_lists_org_id UNIQUE (organization_id, id),
CONSTRAINT fk_candidate_lists_owner FOREIGN KEY (organization_id, owner_user_id)
REFERENCES users (organization_id, id) ON DELETE CASCADE,
CONSTRAINT ck_candidate_list_name CHECK (length(btrim(name)) BETWEEN 1 AND 120)
);
CREATE UNIQUE INDEX uq_candidate_lists_name ON candidate_lists (organization_id, lower(btrim(name)))
WHERE status = 'active';
ALTER TABLE candidate_lists ENABLE ROW LEVEL SECURITY;
ALTER TABLE candidate_lists FORCE ROW LEVEL SECURITY;
CREATE POLICY candidate_lists_isolation ON candidate_lists
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 list is ARCHIVED, because "who did we shortlist for that bid in March" is a question somebody
-- will ask in June, and because a do_not_contact list that can be deleted is a compliance record that can be
-- deleted.
GRANT SELECT, INSERT, UPDATE ON candidate_lists TO missionmatch_app;
-- ============================================================================
-- 3. candidate_list_members
-- ============================================================================
CREATE TABLE candidate_list_members (
organization_id uuid NOT NULL REFERENCES organizations(id),
list_id uuid NOT NULL,
candidate_id uuid NOT NULL,
-- Why this person is on this list. One line, written when they were added, readable by whoever picks the
-- list up later. A shortlist with no reasons is a list somebody has to rebuild from memory.
note text NOT NULL DEFAULT '',
added_by uuid,
added_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (organization_id, list_id, candidate_id),
CONSTRAINT fk_list_members_list FOREIGN KEY (organization_id, list_id)
REFERENCES candidate_lists (organization_id, id) ON DELETE CASCADE,
CONSTRAINT fk_list_members_candidate FOREIGN KEY (organization_id, candidate_id)
REFERENCES candidates (organization_id, id) ON DELETE CASCADE,
CONSTRAINT fk_list_members_adder FOREIGN KEY (organization_id, added_by)
REFERENCES users (organization_id, id) ON DELETE SET NULL,
CONSTRAINT ck_list_member_note CHECK (length(note) <= 500)
);
-- The reverse lookup: which lists is this person on. Asked on every candidate screen, so it gets its own
-- index rather than a sequential scan of the membership table.
CREATE INDEX idx_list_members_candidate ON candidate_list_members (organization_id, candidate_id);
CREATE INDEX idx_list_members_list ON candidate_list_members (organization_id, list_id, added_at DESC);
ALTER TABLE candidate_list_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE candidate_list_members FORCE ROW LEVEL SECURITY;
CREATE POLICY candidate_list_members_isolation ON candidate_list_members
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);
-- DELETE is granted here, unlike the list itself. Removing somebody from a shortlist is a correction, not the
-- destruction of a record, and the alternative is shortlists that only ever grow.
GRANT SELECT, INSERT, UPDATE, DELETE ON candidate_list_members TO missionmatch_app;
-- ============================================================================
-- 4. the indexes search needs that did not exist
-- ============================================================================
-- The GIN index over search_tsv and the HNSW index over resume_embedding have been there since 0002. These
-- are the STRUCTURED filters a recruiter combines with the text: clearance and availability together are the
-- first question asked on a cleared desk, and years of experience is the usual third.
CREATE INDEX idx_candidates_clearance_avail ON candidates (organization_id, clearance_level, available)
WHERE deleted_at IS NULL;
CREATE INDEX idx_candidates_years ON candidates (organization_id, years_experience)
WHERE deleted_at IS NULL;
SELECT mm_mirror_staging_role_privileges();
-- +goose Down
DROP INDEX IF EXISTS idx_candidates_years;
DROP INDEX IF EXISTS idx_candidates_clearance_avail;
DROP TABLE IF EXISTS candidate_list_members;
DROP TABLE IF EXISTS candidate_lists;
DROP TABLE IF EXISTS saved_searches;