diff --git a/.env.example b/.env.example index b8fedb2..4e176c6 100644 --- a/.env.example +++ b/.env.example @@ -28,10 +28,12 @@ TEMPORAL_PORT=7233 TEMPORAL_UI_PORT=8080 # CPB (Connecting People Bot) — Development -## PostgreSQL provisioning (used by init-db.sh to create the database and role) +## PostgreSQL provisioning (used by create-role.sh and setup-db.sh) POSTGRES_DB_CPB=cpb_bot POSTGRES_USER_CPB=cpb_app POSTGRES_PASSWORD_CPB=cpb_password +## RDS master password (one-time use by create-role.sh, remove after provisioning) +# POSTGRES_PASSWORD_MASTER= ## CPB application connection (passed to the app at runtime) CPB_POSTGRES_HOST=postgresql CPB_POSTGRES_DB=cpb_bot diff --git a/scripts/cpb-setup-db.sh b/scripts/cpb-setup-db.sh deleted file mode 100755 index 08afd56..0000000 --- a/scripts/cpb-setup-db.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/bin/bash -set -eo pipefail - -# ============================================================================= -# CPB Production Database Setup -# ============================================================================= -# Creates the cpb_bot database and cpb_app user on an external PostgreSQL server. -# Idempotent — safe to re-run. Does not modify existing data. -# -# Usage: -# POSTGRES_PASSWORD_CPB="" CPB_POSTGRES_HOST="" ./scripts/cpb-setup-db.sh -# -# Required env vars: -# CPB_POSTGRES_HOST — PostgreSQL server hostname or IP -# POSTGRES_PASSWORD_CPB — Password for the cpb_app user -# -# Optional env vars: -# CPB_POSTGRES_PORT — PostgreSQL port (default: 5432) -# CPB_POSTGRES_ADMIN_USER — Admin user for psql connection (default: postgres) -# POSTGRES_DB_CPB — Database name (default: cpb_bot) -# POSTGRES_USER_CPB — Username (default: cpb_app) -# ============================================================================= - -PGHOST="${CPB_POSTGRES_HOST:?CPB_POSTGRES_HOST is required}" -PGPORT="${CPB_POSTGRES_PORT:-5432}" -PGADMIN_USER="${CPB_POSTGRES_ADMIN_USER:-postgres}" -CPB_DB="${POSTGRES_DB_CPB:-cpb_bot}" -CPB_USER="${POSTGRES_USER_CPB:-cpb_app}" -CPB_PASS="${POSTGRES_PASSWORD_CPB:?POSTGRES_PASSWORD_CPB is required}" - -# Validate PostgreSQL identifiers (prevent SQL injection via crafted names) -validate_pg_identifier() { - local value="$1" name="$2" - if [[ -z "$value" ]]; then - echo "ERROR: ${name} cannot be empty" >&2; exit 1 - fi - if [[ ${#value} -gt 63 ]]; then - echo "ERROR: ${name} exceeds PostgreSQL's 63-char identifier limit" >&2; exit 1 - fi - if [[ ! "$value" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then - echo "ERROR: ${name} contains invalid characters (must match ^[a-zA-Z_][a-zA-Z0-9_]*$)" >&2; exit 1 - fi - return 0 -} -validate_pg_identifier "$CPB_USER" "POSTGRES_USER_CPB" -validate_pg_identifier "$CPB_DB" "POSTGRES_DB_CPB" - -# Escape single quotes in password for SQL string literal safety -CPB_PASS_SQL="${CPB_PASS//\'/''}" - -echo "Setting up CPB database on ${PGHOST}:${PGPORT}..." -echo " Database: ${CPB_DB}" -echo " User: ${CPB_USER}" - -psql -v ON_ERROR_STOP=1 -h "$PGHOST" -p "$PGPORT" -U "$PGADMIN_USER" <<-EOSQL --- Create role if it doesn't exist -DO \$\$ -BEGIN - IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '${CPB_USER}') THEN - CREATE ROLE "${CPB_USER}" WITH LOGIN ENCRYPTED PASSWORD '${CPB_PASS_SQL}'; - RAISE NOTICE 'Created role: ${CPB_USER}'; - ELSE - RAISE NOTICE 'Role already exists: ${CPB_USER}'; - END IF; -END -\$\$; - --- Create database if it doesn't exist -SELECT 'CREATE DATABASE "${CPB_DB}" OWNER "${CPB_USER}"' -WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${CPB_DB}')\gexec - --- Grant privileges (idempotent) -GRANT ALL PRIVILEGES ON DATABASE "${CPB_DB}" TO "${CPB_USER}"; -EOSQL - -echo "" -echo "CPB database setup complete." -echo " Database: ${CPB_DB}" -echo " User: ${CPB_USER}" -echo " Host: ${PGHOST}:${PGPORT}" diff --git a/scripts/cpb/create-role.sh b/scripts/cpb/create-role.sh new file mode 100755 index 0000000..11ddb1c --- /dev/null +++ b/scripts/cpb/create-role.sh @@ -0,0 +1,185 @@ +#!/bin/bash +set -eo pipefail + +# ============================================================================= +# CPB Role & Database Provisioning (one-time setup) +# ============================================================================= +# Connects to an external PostgreSQL (RDS) server using the master password to +# create a dedicated cpb_app role and cpb_bot database. This replaces the +# interim approach of using the temporal user as database owner. +# +# Designed as a one-time provisioning tool: +# 1. Run this script with the RDS master password +# 2. Run setup-db.sh to create the schema +# 3. Remove the master password from your environment +# +# Idempotency: +# - Safe to re-run. Existing role gets its password updated; existing +# database is left untouched; ownership is transferred only if needed. +# +# Usage: +# CPB_POSTGRES_HOST="" \ +# POSTGRES_PASSWORD_MASTER="" \ +# POSTGRES_PASSWORD_CPB="" \ +# ./scripts/cpb/create-role.sh +# +# Required env vars: +# CPB_POSTGRES_HOST — PostgreSQL server hostname or IP +# POSTGRES_PASSWORD_MASTER — RDS master (postgres) password +# POSTGRES_PASSWORD_CPB — Password to assign to the cpb_app role +# +# Optional env vars: +# CPB_POSTGRES_PORT — PostgreSQL port (default: 5432) +# POSTGRES_USER_MASTER — Master user name (default: postgres) +# POSTGRES_USER_CPB — CPB role name (default: cpb_app) +# POSTGRES_DB_CPB — Database name (default: cpb_bot) +# ============================================================================= + +# --- Required env vars (fail immediately if missing) -------------------------- +PGHOST="${CPB_POSTGRES_HOST:?CPB_POSTGRES_HOST is required}" +MASTER_PASS="${POSTGRES_PASSWORD_MASTER:?POSTGRES_PASSWORD_MASTER is required}" +CPB_PASS="${POSTGRES_PASSWORD_CPB:?POSTGRES_PASSWORD_CPB is required}" + +# --- Optional env vars with defaults ----------------------------------------- +PGPORT="${CPB_POSTGRES_PORT:-5432}" +MASTER_USER="${POSTGRES_USER_MASTER:-postgres}" +CPB_USER="${POSTGRES_USER_CPB:-cpb_app}" +CPB_DB="${POSTGRES_DB_CPB:-cpb_bot}" + +# --- Validate identifiers (prevent SQL injection via crafted names) ----------- +validate_pg_identifier() { + local value="$1" name="$2" + if [[ -z "$value" ]]; then + echo "ERROR: ${name} cannot be empty" >&2; exit 1 + fi + if [[ ${#value} -gt 63 ]]; then + echo "ERROR: ${name} exceeds PostgreSQL's 63-char identifier limit" >&2; exit 1 + fi + if [[ ! "$value" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then + echo "ERROR: ${name} contains invalid characters (must match ^[a-zA-Z_][a-zA-Z0-9_]*$)" >&2; exit 1 + fi + return 0 +} +validate_pg_identifier "$MASTER_USER" "POSTGRES_USER_MASTER" +validate_pg_identifier "$CPB_USER" "POSTGRES_USER_CPB" +validate_pg_identifier "$CPB_DB" "POSTGRES_DB_CPB" + +# --- Escape single quotes in passwords for safe SQL embedding ----------------- +# Also reject passwords containing $$ which would break PL/pgSQL dollar-quoting +if [[ "$CPB_PASS" == *'$$'* ]]; then + echo "ERROR: POSTGRES_PASSWORD_CPB must not contain '\$\$' (breaks PL/pgSQL quoting)" >&2 + exit 1 +fi +ESCAPED_CPB_PASS="${CPB_PASS//\'/''}" + +echo "=== CPB Role & Database Provisioning ===" +echo " Host: ${PGHOST}:${PGPORT}" +echo " Master: ${MASTER_USER}" +echo " Role: ${CPB_USER}" +echo " Database: ${CPB_DB}" +echo "" + +# --- Step 1: Create role (idempotent) ---------------------------------------- +# If the role already exists, update its password to converge to desired state. +echo "Step 1: Ensuring role '${CPB_USER}' exists..." +PGPASSWORD="${MASTER_PASS}" psql -v ON_ERROR_STOP=1 \ + -h "$PGHOST" -p "$PGPORT" -U "$MASTER_USER" -d postgres <<-EOSQL +DO \$\$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${CPB_USER}') THEN + CREATE ROLE "${CPB_USER}" LOGIN ENCRYPTED PASSWORD '${ESCAPED_CPB_PASS}'; + RAISE NOTICE 'Created role ${CPB_USER}'; + ELSE + ALTER ROLE "${CPB_USER}" WITH LOGIN ENCRYPTED PASSWORD '${ESCAPED_CPB_PASS}'; + RAISE NOTICE 'Role ${CPB_USER} already exists — password updated'; + END IF; +END +\$\$; +EOSQL +echo " Role '${CPB_USER}': OK" + +# --- Step 2: Create database (idempotent) ------------------------------------ +echo "Step 2: Ensuring database '${CPB_DB}' exists..." +PGPASSWORD="${MASTER_PASS}" psql -v ON_ERROR_STOP=1 \ + -h "$PGHOST" -p "$PGPORT" -U "$MASTER_USER" -d postgres <<-EOSQL +SELECT 'CREATE DATABASE "${CPB_DB}" OWNER "${CPB_USER}"' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${CPB_DB}')\gexec +EOSQL +echo " Database '${CPB_DB}': OK" + +# --- Step 3: Transfer ownership if needed (idempotent) ----------------------- +echo "Step 3: Ensuring '${CPB_USER}' owns '${CPB_DB}'..." +PGPASSWORD="${MASTER_PASS}" psql -v ON_ERROR_STOP=1 \ + -h "$PGHOST" -p "$PGPORT" -U "$MASTER_USER" -d postgres <<-EOSQL +DO \$\$ +DECLARE + current_owner TEXT; +BEGIN + SELECT pg_catalog.pg_get_userbyid(d.datdba) INTO current_owner + FROM pg_database d WHERE d.datname = '${CPB_DB}'; + + IF current_owner IS NULL THEN + RAISE EXCEPTION 'Database ${CPB_DB} not found — this should not happen'; + ELSIF current_owner = '${CPB_USER}' THEN + RAISE NOTICE 'Database ${CPB_DB} already owned by ${CPB_USER}'; + ELSE + EXECUTE 'ALTER DATABASE "${CPB_DB}" OWNER TO "${CPB_USER}"'; + RAISE NOTICE 'Transferred ownership from % to ${CPB_USER}', current_owner; + END IF; +END +\$\$; +EOSQL +echo " Ownership: OK" + +# --- Step 3b: Reassign object ownership within database (idempotent) --------- +# ALTER DATABASE OWNER only transfers the database-level ownership. Objects +# inside (tables, sequences, functions, triggers) keep their original owner. +# On migration from old setup (temporal/n8n owned objects), reassign them all. +echo " Reassigning objects within '${CPB_DB}'..." +PGPASSWORD="${MASTER_PASS}" psql -v ON_ERROR_STOP=1 \ + -h "$PGHOST" -p "$PGPORT" -U "$MASTER_USER" -d "$CPB_DB" <<-EOSQL +DO \$\$ +DECLARE + role_name TEXT; +BEGIN + FOR role_name IN + SELECT DISTINCT r.rolname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_roles r ON r.oid = c.relowner + WHERE n.nspname = 'public' + AND r.rolname NOT IN ('${CPB_USER}', '${MASTER_USER}') + LOOP + EXECUTE format('REASSIGN OWNED BY %I TO %I', role_name, '${CPB_USER}'); + RAISE NOTICE 'Reassigned objects from % to ${CPB_USER}', role_name; + END LOOP; +END +\$\$; +EOSQL +echo " Object ownership: OK" + +# --- Step 4: Grant database-level privileges (idempotent) -------------------- +echo "Step 4: Granting database privileges..." +PGPASSWORD="${MASTER_PASS}" psql -v ON_ERROR_STOP=1 \ + -h "$PGHOST" -p "$PGPORT" -U "$MASTER_USER" -d postgres <<-EOSQL +GRANT ALL PRIVILEGES ON DATABASE "${CPB_DB}" TO "${CPB_USER}"; +EOSQL +echo " Database GRANT: OK" + +# --- Step 5: Grant schema-level privileges (idempotent) ---------------------- +# As superuser we can always grant on public schema, regardless of PG version. +echo "Step 5: Granting schema privileges on '${CPB_DB}'..." +PGPASSWORD="${MASTER_PASS}" psql -v ON_ERROR_STOP=1 \ + -h "$PGHOST" -p "$PGPORT" -U "$MASTER_USER" -d "$CPB_DB" <<-EOSQL +GRANT ALL ON SCHEMA public TO "${CPB_USER}"; +EOSQL +echo " Schema GRANT: OK" + +echo "" +echo "=== Provisioning complete ===" +echo " Role: ${CPB_USER} (LOGIN)" +echo " Database: ${CPB_DB} (owned by ${CPB_USER})" +echo "" +echo "Next steps:" +echo " 1. Run setup-db.sh to create the schema tables" +echo " 2. Remove POSTGRES_PASSWORD_MASTER from your environment" diff --git a/scripts/cpb/setup-db.sh b/scripts/cpb/setup-db.sh new file mode 100755 index 0000000..500eef8 --- /dev/null +++ b/scripts/cpb/setup-db.sh @@ -0,0 +1,120 @@ +#!/bin/bash +set -eo pipefail + +# ============================================================================= +# CPB Schema Setup +# ============================================================================= +# Connects to the cpb_bot database as the cpb_app role and applies the +# init-schema.sql file to create tables, indexes, and triggers. +# +# Prerequisites: +# - Role cpb_app and database cpb_bot must already exist. +# Run create-role.sh first if they don't. +# +# Idempotency: +# - Safe to re-run. All DDL uses IF NOT EXISTS / CREATE OR REPLACE. +# +# Usage: +# CPB_POSTGRES_HOST="" POSTGRES_PASSWORD_CPB="" \ +# ./scripts/cpb/setup-db.sh +# +# Required env vars: +# CPB_POSTGRES_HOST — PostgreSQL server hostname or IP +# POSTGRES_PASSWORD_CPB — Password for the cpb_app role +# +# Optional env vars: +# CPB_POSTGRES_PORT — PostgreSQL port (default: 5432) +# POSTGRES_USER_CPB — CPB role name (default: cpb_app) +# POSTGRES_DB_CPB — Database name (default: cpb_bot) +# ============================================================================= + +# --- Required env vars (fail immediately if missing) -------------------------- +PGHOST="${CPB_POSTGRES_HOST:?CPB_POSTGRES_HOST is required}" +CPB_PASS="${POSTGRES_PASSWORD_CPB:?POSTGRES_PASSWORD_CPB is required}" + +# --- Optional env vars with defaults ----------------------------------------- +PGPORT="${CPB_POSTGRES_PORT:-5432}" +CPB_USER="${POSTGRES_USER_CPB:-cpb_app}" +CPB_DB="${POSTGRES_DB_CPB:-cpb_bot}" + +# --- Resolve script directory for locating SQL files -------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SCHEMA_FILE="${REPO_ROOT}/sql/cpb/init-schema.sql" + +# --- Validate identifiers (prevent SQL injection via crafted names) ----------- +validate_pg_identifier() { + local value="$1" name="$2" + if [[ -z "$value" ]]; then + echo "ERROR: ${name} cannot be empty" >&2; exit 1 + fi + if [[ ${#value} -gt 63 ]]; then + echo "ERROR: ${name} exceeds PostgreSQL's 63-char identifier limit" >&2; exit 1 + fi + if [[ ! "$value" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then + echo "ERROR: ${name} contains invalid characters (must match ^[a-zA-Z_][a-zA-Z0-9_]*$)" >&2; exit 1 + fi + return 0 +} +validate_pg_identifier "$CPB_USER" "POSTGRES_USER_CPB" +validate_pg_identifier "$CPB_DB" "POSTGRES_DB_CPB" + +# --- Verify schema file exists ------------------------------------------------ +if [[ ! -f "$SCHEMA_FILE" ]]; then + echo "ERROR: Schema file not found: ${SCHEMA_FILE}" >&2 + echo " Expected at: sql/cpb/init-schema.sql (relative to repo root)" >&2 + exit 1 +fi + +echo "=== CPB Schema Setup ===" +echo " Host: ${PGHOST}:${PGPORT}" +echo " User: ${CPB_USER}" +echo " Database: ${CPB_DB}" +echo " Schema: ${SCHEMA_FILE}" +echo "" + +# --- Step 1: Verify connectivity --------------------------------------------- +echo "Step 1: Verifying database connection..." +if ! CONN_ERR=$(PGPASSWORD="${CPB_PASS}" psql -v ON_ERROR_STOP=1 \ + -h "$PGHOST" -p "$PGPORT" -U "$CPB_USER" -d "$CPB_DB" \ + -c "SELECT 1;" 2>&1 >/dev/null); then + echo "ERROR: Cannot connect to ${CPB_DB} as ${CPB_USER}" >&2 + echo " psql: ${CONN_ERR}" >&2 + echo " Have you run create-role.sh first?" >&2 + exit 1 +fi +echo " Connection: OK" + +# --- Step 2: Apply schema ---------------------------------------------------- +echo "Step 2: Applying schema from init-schema.sql..." +PGPASSWORD="${CPB_PASS}" psql -v ON_ERROR_STOP=1 \ + -h "$PGHOST" -p "$PGPORT" -U "$CPB_USER" -d "$CPB_DB" \ + -f "$SCHEMA_FILE" +echo " Schema applied: OK" + +# --- Step 3: Verify tables were created --------------------------------------- +echo "Step 3: Verifying tables..." +TABLE_COUNT=$(PGPASSWORD="${CPB_PASS}" psql -v ON_ERROR_STOP=1 -t -A \ + -h "$PGHOST" -p "$PGPORT" -U "$CPB_USER" -d "$CPB_DB" \ + -c "SELECT count(*) FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + AND table_name IN ( + 'cycles', 'opt_in_responses', 'pairings', + 'pair_history', 'interactions', 'admin_reports' + );") + +if [[ "$TABLE_COUNT" -eq 6 ]]; then + echo " All 6 tables verified: OK" +else + echo "ERROR: Expected 6 tables, found ${TABLE_COUNT}" >&2 + echo " Re-run the psql command manually with -v to investigate" >&2 + exit 1 +fi + +echo "" +echo "=== Schema setup complete ===" +echo " Database: ${CPB_DB}" +echo " User: ${CPB_USER}" +echo " Tables: ${TABLE_COUNT}/6" +echo " Host: ${PGHOST}:${PGPORT}" diff --git a/sql/cpb/init-schema.sql b/sql/cpb/init-schema.sql new file mode 100644 index 0000000..187fdb0 --- /dev/null +++ b/sql/cpb/init-schema.sql @@ -0,0 +1,206 @@ +-- ============================================================================= +-- CPB (Connecting People Bot) - Database Schema +-- ============================================================================= +-- Database: cpb_bot +-- Engine: PostgreSQL 14+ +-- Purpose: Complete schema for the CPB Slack bot that randomly pairs +-- employees for monthly virtual coffee 1-on-1 meetings. +-- +-- Usage: psql -h -U cpb_app -d cpb_bot -f init-schema.sql +-- +-- Notes: +-- - All CREATE TABLE / CREATE INDEX use IF NOT EXISTS for idempotent re-runs +-- - No transaction wrapper (each statement runs independently) +-- - Status/response columns use VARCHAR + CHECK constraints +-- - Trigger function auto-updates updated_at on mutable tables +-- ============================================================================= + + +-- --------------------------------------------------------------------------- +-- Trigger function: auto-update updated_at on row modification +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + + +-- --------------------------------------------------------------------------- +-- Table: cycles +-- One row per monthly cycle. Central entity that all other tables reference. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS cycles ( + id SERIAL PRIMARY KEY, + year_month VARCHAR(7) NOT NULL UNIQUE -- e.g. '2026-03' + CHECK (year_month ~ '^\d{4}-(0[1-9]|1[0-2])$'), + opt_in_send_at TIMESTAMPTZ NOT NULL, + pairing_send_at TIMESTAMPTZ NOT NULL, + checkin_send_at TIMESTAMPTZ NOT NULL, + survey_send_at TIMESTAMPTZ NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (status IN ( + 'pending', + 'opt_in_sent', + 'paired', + 'checkin_sent', + 'survey_sent', + 'completed' + )), + eligible_count INTEGER, + opted_in_count INTEGER, + paired_count INTEGER, + unpaired_count INTEGER, + avg_pair_weight NUMERIC(5,3), -- time-decay algorithm quality metric + repeat_pair_count INTEGER, -- how many pairs are repeats this cycle + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +DROP TRIGGER IF EXISTS trg_cycles_updated_at ON cycles; +CREATE TRIGGER trg_cycles_updated_at + BEFORE UPDATE ON cycles + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + + +-- --------------------------------------------------------------------------- +-- Table: opt_in_responses +-- One row per person per cycle. UPSERT on (cycle_id, slack_user_id) = last click wins. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS opt_in_responses ( + id SERIAL PRIMARY KEY, + cycle_id INTEGER NOT NULL REFERENCES cycles(id), + slack_user_id VARCHAR(20) NOT NULL, + slack_display_name VARCHAR(200), + message_ts VARCHAR(30), -- for chat.update + channel_id VARCHAR(20), -- DM channel + response VARCHAR(20) + CHECK (response IN ( + 'definitely_yes', + 'dont_mind', + 'skip' + )), -- NULL = no response yet + responded_at TIMESTAMPTZ, + message_sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (cycle_id, slack_user_id) +); + +DROP TRIGGER IF EXISTS trg_opt_in_responses_updated_at ON opt_in_responses; +CREATE TRIGGER trg_opt_in_responses_updated_at + BEFORE UPDATE ON opt_in_responses + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + + +-- --------------------------------------------------------------------------- +-- Table: pairings +-- One row per pair per cycle. Canonical ordering enforced: person_a_id < person_b_id. +-- Same pair may recur across cycles (repeats tracked via is_repeat flag). +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS pairings ( + id SERIAL PRIMARY KEY, + cycle_id INTEGER NOT NULL REFERENCES cycles(id), + person_a_id VARCHAR(20) NOT NULL, -- always lexicographically smaller + person_b_id VARCHAR(20) NOT NULL, -- always lexicographically larger + scheduler_id VARCHAR(20) NOT NULL, + is_repeat BOOLEAN NOT NULL DEFAULT false, + pair_weight NUMERIC(5,3), -- algorithm weight at time of pairing + person_a_notified_at TIMESTAMPTZ, + person_b_notified_at TIMESTAMPTZ, + person_a_msg_ts VARCHAR(30), + person_b_msg_ts VARCHAR(30), + person_a_dm_channel VARCHAR(20), + person_b_dm_channel VARCHAR(20), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK (person_a_id < person_b_id), -- ordering invariant + UNIQUE (cycle_id, person_a_id, person_b_id) -- one pair per cycle +); + +CREATE INDEX IF NOT EXISTS idx_pairings_pair + ON pairings(person_a_id, person_b_id); + +DROP TRIGGER IF EXISTS trg_pairings_updated_at ON pairings; +CREATE TRIGGER trg_pairings_updated_at + BEFORE UPDATE ON pairings + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + + +-- --------------------------------------------------------------------------- +-- Table: pair_history +-- Materialized summary for the pairing algorithm. Updated via UPSERT after each cycle. +-- Rebuildable from pairings table if corrupted. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS pair_history ( + person_a_id VARCHAR(20) NOT NULL, + person_b_id VARCHAR(20) NOT NULL, + pair_count INTEGER NOT NULL DEFAULT 0, + last_cycle_id INTEGER NOT NULL REFERENCES cycles(id), + last_paired_at TIMESTAMPTZ NOT NULL, + first_paired_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (person_a_id, person_b_id), + CHECK (person_a_id < person_b_id) -- ordering invariant +); + +CREATE INDEX IF NOT EXISTS idx_pair_history_last_cycle + ON pair_history(last_cycle_id); + + +-- --------------------------------------------------------------------------- +-- Table: interactions +-- Append-only audit log. Every button click = new row. Never updated or deleted. +-- Resolved state (last click) derived via DISTINCT ON queries. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS interactions ( + id SERIAL PRIMARY KEY, + cycle_id INTEGER NOT NULL REFERENCES cycles(id), + slack_user_id VARCHAR(20) NOT NULL, + pairing_id INTEGER REFERENCES pairings(id), -- NULL for opt-in (no pairing yet) + touchpoint VARCHAR(20) NOT NULL, + action VARCHAR(30) NOT NULL, + CONSTRAINT interactions_touchpoint_action_valid CHECK ( + (touchpoint = 'opt_in' AND action IN ('definitely_yes', 'dont_mind', 'skip')) + OR (touchpoint = 'checkin' AND action IN ('checkin_yes', 'checkin_no')) + OR (touchpoint = 'survey' AND action IN ('satisfied', 'unsatisfied', 'didnt_happen')) + ), + raw_payload JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_interactions_cycle_user + ON interactions(cycle_id, slack_user_id); + +CREATE INDEX IF NOT EXISTS idx_interactions_cycle_touchpoint + ON interactions(cycle_id, touchpoint); + +CREATE INDEX IF NOT EXISTS idx_interactions_pairing + ON interactions(pairing_id); + + +-- --------------------------------------------------------------------------- +-- Table: admin_reports +-- One report per cycle. Tracks generation and delivery to Slack admin channel. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS admin_reports ( + id SERIAL PRIMARY KEY, + cycle_id INTEGER NOT NULL UNIQUE REFERENCES cycles(id), + report_data JSONB NOT NULL, + sent_to_slack BOOLEAN NOT NULL DEFAULT false, + slack_channel_id VARCHAR(20), + slack_message_ts VARCHAR(30), + sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +DROP TRIGGER IF EXISTS trg_admin_reports_updated_at ON admin_reports; +CREATE TRIGGER trg_admin_reports_updated_at + BEFORE UPDATE ON admin_reports + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column();