Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / db/migrations/0088_teams_and_record_scope.sql |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | db/migrations/0088_teams_and_record_scope.sql |
| Original SHA-256 | 92fa57ef0293d4e5398e270f402fb6630f32e51ab3858018f17f2f4ad4af5e2a |
| Git blob SHA | 493816799d99e96c464f03099539c41eb9998ed4 |
| Reading-copy SHA-256 | 92fa57ef0293d4e5398e270f402fb6630f32e51ab3858018f17f2f4ad4af5e2a |
Recovered source transcript
Download complete source text ↓141 lines · 8.5 KB
-- +goose Up
-- ACCESS LEVELS, THE THIRD AXIS. Directive: build access out to what business and industry standard are.
--
-- A recruiting platform's access model has three axes and this schema had one and a half.
--
-- 1. WHAT ACTIONS may I take. The capability matrix, six roles and two dozen capabilities. Already correct.
-- 2. WHICH FIELDS may I see on a record I can read. Field sensitivity, migration 0087. Done.
-- 3. WHICH RECORDS may I see at all. own, my team's, or the whole organization. THIS IS THE MISSING ONE,
-- and it is the axis every established product in this market has: Bullhorn, Greenhouse, Lever and
-- Salesforce all express it as owner, team and organization.
--
-- Two things were missing under it. There was no notion of a TEAM, so "my team's records" could not be
-- expressed, and CANDIDATES HAD NO OWNER, so the single most important record type in the product could not
-- be scoped at all. Both are added here.
--
-- THE DEFAULT IS ORGANIZATION, WHICH IS EXACTLY TODAY'S BEHAVIOUR. Nothing narrows until an owner chooses to
-- narrow it. A migration that silently hid records from people who could see them yesterday would be a
-- support incident on the morning it shipped, not a feature.
--
-- OWNER AND ADMIN ARE ALWAYS ORGANIZATION WIDE AND THAT IS NOT CONFIGURABLE. Somebody has to be able to see
-- everything, and it is the person accountable for the account. A product that lets an administrator lock
-- themselves out of their own data is a product that generates a support call it cannot resolve.
-- ============================================================================
-- 1. teams, the unit a desk belongs to
-- ============================================================================
CREATE TABLE teams (
organization_id uuid NOT NULL REFERENCES organizations(id),
id uuid NOT NULL DEFAULT gen_random_uuid(),
name text NOT NULL,
-- The lead is a convenience for display and for routing, NOT an authority. Authority comes from the role
-- on the ladder, so making somebody a team lead does not quietly promote them.
lead_user_id uuid,
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 fk_teams_lead FOREIGN KEY (organization_id, lead_user_id)
REFERENCES users (organization_id, id) ON DELETE SET NULL,
CONSTRAINT ck_teams_name CHECK (length(btrim(name)) BETWEEN 1 AND 120)
);
-- Archived teams keep their name out of the way without deleting the history of who was on which desk.
CREATE UNIQUE INDEX uq_teams_name ON teams (organization_id, lower(btrim(name))) WHERE status = 'active';
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
ALTER TABLE teams FORCE ROW LEVEL SECURITY;
CREATE POLICY teams_isolation ON teams
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 teams TO missionmatch_app;
-- ============================================================================
-- 2. team_members
-- ============================================================================
-- A person may belong to MORE THAN ONE team, deliberately. Agencies run people across desks, and a model
-- that forces one team per person makes the common case a lie. Team scope therefore means the union of every
-- team the caller belongs to.
CREATE TABLE team_members (
organization_id uuid NOT NULL REFERENCES organizations(id),
team_id uuid NOT NULL,
user_id uuid NOT NULL,
added_at timestamptz NOT NULL DEFAULT now(),
added_by uuid,
PRIMARY KEY (organization_id, team_id, user_id),
CONSTRAINT fk_team_members_team FOREIGN KEY (organization_id, team_id)
REFERENCES teams (organization_id, id) ON DELETE CASCADE,
CONSTRAINT fk_team_members_user FOREIGN KEY (organization_id, user_id)
REFERENCES users (organization_id, id) ON DELETE CASCADE,
CONSTRAINT fk_team_members_adder FOREIGN KEY (organization_id, added_by)
REFERENCES users (organization_id, id) ON DELETE SET NULL
);
CREATE INDEX idx_team_members_user ON team_members (organization_id, user_id);
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE team_members FORCE ROW LEVEL SECURITY;
CREATE POLICY team_members_isolation ON team_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 because removing somebody from a desk is not a historical claim, it is a correction, and
-- the audit trail records the change. This matches the reasoning already recorded for org_secrets.
GRANT SELECT, INSERT, UPDATE, DELETE ON team_members TO missionmatch_app;
-- ============================================================================
-- 3. candidates get an owner
-- ============================================================================
-- The gap that made record scope impossible for the record type that matters most. NULLABLE, because the
-- entire existing bench is unowned and inventing an owner for it would be a fabrication. An unowned candidate
-- is visible at every scope, which is the correct reading: nobody has claimed responsibility for them, so
-- nobody is excluded from picking them up.
ALTER TABLE candidates ADD COLUMN owner_user_id uuid;
ALTER TABLE candidates ADD CONSTRAINT fk_candidates_owner
FOREIGN KEY (organization_id, owner_user_id) REFERENCES users (organization_id, id) ON DELETE SET NULL;
CREATE INDEX idx_candidates_owner ON candidates (organization_id, owner_user_id)
WHERE owner_user_id IS NOT NULL;
-- ============================================================================
-- 4. the scope settings
-- ============================================================================
-- TWO settings rather than one per role, because the ladder already has the answer for the other four.
-- Owner and admin are organization wide and not configurable. Manager either sees their team or everything.
-- Everyone below manager shares one setting, since recruiter, member and viewer are all "staff on a desk"
-- from a data scoping point of view even though their capabilities differ sharply.
ALTER TABLE org_settings ADD COLUMN staff_record_scope text NOT NULL DEFAULT 'organization'
CHECK (staff_record_scope IN ('own', 'team', 'organization'));
ALTER TABLE org_settings ADD COLUMN manager_record_scope text NOT NULL DEFAULT 'organization'
CHECK (manager_record_scope IN ('team', 'organization'));
-- Whether a record with no owner is visible to everyone. Default true, because an unowned candidate that
-- nobody can see is a candidate nobody will ever work, which is worse than one everybody can see.
ALTER TABLE org_settings ADD COLUMN unowned_records_visible boolean NOT NULL DEFAULT true;
-- ============================================================================
-- 5. the resolver, in the database, so one definition serves every caller
-- ============================================================================
-- +goose StatementBegin
-- mm_team_peer_ids returns every user who shares at least one team with the given user, INCLUDING that user.
-- Including them matters: a person on no team must still see their own records under team scope, or joining
-- an organization before being assigned a desk would make your own work disappear.
CREATE OR REPLACE FUNCTION mm_team_peer_ids(p_user uuid) RETURNS TABLE (user_id uuid) AS $$
SELECT p_user
UNION
SELECT tm2.user_id
FROM team_members tm1
JOIN team_members tm2
ON tm2.organization_id = tm1.organization_id
AND tm2.team_id = tm1.team_id
WHERE tm1.user_id = p_user;
$$ LANGUAGE sql STABLE;
-- +goose StatementEnd
GRANT EXECUTE ON FUNCTION mm_team_peer_ids(uuid) TO missionmatch_app;
SELECT mm_mirror_staging_role_privileges();
-- +goose Down
DROP FUNCTION IF EXISTS mm_team_peer_ids(uuid);
ALTER TABLE org_settings DROP COLUMN IF EXISTS unowned_records_visible;
ALTER TABLE org_settings DROP COLUMN IF EXISTS manager_record_scope;
ALTER TABLE org_settings DROP COLUMN IF EXISTS staff_record_scope;
DROP INDEX IF EXISTS idx_candidates_owner;
ALTER TABLE candidates DROP CONSTRAINT IF EXISTS fk_candidates_owner;
ALTER TABLE candidates DROP COLUMN IF EXISTS owner_user_id;
DROP TABLE IF EXISTS team_members;
DROP TABLE IF EXISTS teams;