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/setup-db.sh b/scripts/cpb/setup-db.sh new file mode 100755 index 0000000..a65499f --- /dev/null +++ b/scripts/cpb/setup-db.sh @@ -0,0 +1,109 @@ +#!/bin/bash +set -eo pipefail + +# ============================================================================= +# CPB Production Database Setup +# ============================================================================= +# Creates the cpb_bot database on an external PostgreSQL (RDS) server using the +# temporal user (which has CREATEDB privilege). Grants access to the n8n user +# so the CPB application (running inside n8n) can manage its tables. +# +# Ownership model: +# - temporal creates and owns the database (only user with CREATEDB) +# - n8n gets ALL PRIVILEGES on the database to create/manage tables +# - Later, when DevOps provides the master password, ownership can be +# migrated to a dedicated cpb_app user +# +# PostgreSQL version notes: +# - PG14: public schema grants CREATE to PUBLIC by default — n8n can create +# tables without explicit schema grants +# - PG15+: CREATE on public schema is revoked by default — the script +# attempts to grant schema privileges and warns if it cannot (the DB owner +# can do this on PG15+ since public schema is owned by pg_database_owner) +# +# Usage: +# CPB_POSTGRES_HOST="" POSTGRES_PASSWORD_TEMPORAL="" \ +# POSTGRES_USER_N8N="n8n" ./scripts/cpb/setup-db.sh +# +# Required env vars: +# CPB_POSTGRES_HOST — PostgreSQL server hostname or IP +# POSTGRES_PASSWORD_TEMPORAL — Password for the temporal user +# +# Optional env vars: +# CPB_POSTGRES_PORT — PostgreSQL port (default: 5432) +# POSTGRES_USER_TEMPORAL — Temporal user name (default: temporal) +# POSTGRES_USER_N8N — n8n user to grant access to (default: n8n) +# POSTGRES_DB_CPB — Database name (default: cpb_bot) +# ============================================================================= + +PGHOST="${CPB_POSTGRES_HOST:?CPB_POSTGRES_HOST is required}" +PGPORT="${CPB_POSTGRES_PORT:-5432}" +TEMPORAL_USER="${POSTGRES_USER_TEMPORAL:-temporal}" +N8N_USER="${POSTGRES_USER_N8N:-n8n}" +CPB_DB="${POSTGRES_DB_CPB:-cpb_bot}" + +TEMPORAL_PASS="${POSTGRES_PASSWORD_TEMPORAL:?POSTGRES_PASSWORD_TEMPORAL 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 "$TEMPORAL_USER" "POSTGRES_USER_TEMPORAL" +validate_pg_identifier "$N8N_USER" "POSTGRES_USER_N8N" +validate_pg_identifier "$CPB_DB" "POSTGRES_DB_CPB" + +echo "Setting up CPB database on ${PGHOST}:${PGPORT}..." +echo " Database: ${CPB_DB}" +echo " Owner: ${TEMPORAL_USER} (interim — migrate to dedicated user later)" +echo " Grantee: ${N8N_USER}" + +# --- Step 1: Create database and grant database-level privileges ----------- +# Connect to the 'postgres' maintenance database to run DDL. +# CREATE DATABASE cannot run inside a transaction, so we use \gexec. +PGPASSWORD="${TEMPORAL_PASS}" psql -v ON_ERROR_STOP=1 \ + -h "$PGHOST" -p "$PGPORT" -U "$TEMPORAL_USER" -d postgres <<-EOSQL +-- Create database if it doesn't exist (temporal becomes owner) +SELECT 'CREATE DATABASE "${CPB_DB}"' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${CPB_DB}')\gexec + +-- Grant all database-level privileges to n8n (idempotent) +GRANT ALL PRIVILEGES ON DATABASE "${CPB_DB}" TO "${N8N_USER}"; +EOSQL + +# --- Step 2: Grant schema-level privileges --------------------------------- +# On PG14, this is unnecessary (PUBLIC has CREATE on public schema by default) +# but we attempt it for forward-compatibility with PG15+ where it IS required. +# temporal cannot grant on public schema in PG14 (owned by postgres), so we +# handle the error gracefully. +if SCHEMA_GRANT_ERR=$(PGPASSWORD="${TEMPORAL_PASS}" psql -v ON_ERROR_STOP=1 \ + -h "$PGHOST" -p "$PGPORT" -U "$TEMPORAL_USER" -d "$CPB_DB" \ + -c "GRANT ALL ON SCHEMA public TO \"${N8N_USER}\";" 2>&1); then + echo " Schema grant on public: OK" +else + if echo "$SCHEMA_GRANT_ERR" | grep -qi "permission denied\|must be owner"; then + echo " Schema grant on public: skipped (not needed on PG14 — PUBLIC has CREATE by default)" + echo " NOTE: After upgrading to PG15+, re-run this script or grant manually:" + echo " GRANT ALL ON SCHEMA public TO \"${N8N_USER}\";" + else + echo "ERROR: Schema grant failed unexpectedly:" >&2 + echo " $SCHEMA_GRANT_ERR" >&2 + exit 1 + fi +fi + +echo "" +echo "CPB database setup complete." +echo " Database: ${CPB_DB}" +echo " Owner: ${TEMPORAL_USER}" +echo " Access: ${N8N_USER} (all privileges)" +echo " Host: ${PGHOST}:${PGPORT}" diff --git a/sql/cpb/init-schema.sql b/sql/cpb/init-schema.sql new file mode 100644 index 0000000..d3fd21f --- /dev/null +++ b/sql/cpb/init-schema.sql @@ -0,0 +1,216 @@ +-- ============================================================================= +-- 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' + 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) +); + +CREATE INDEX IF NOT EXISTS idx_opt_in_responses_cycle + ON opt_in_responses(cycle_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. +-- Repeats allowed (no UNIQUE on pair columns). +-- --------------------------------------------------------------------------- +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(), + CHECK (person_a_id < person_b_id) -- ordering invariant +); + +CREATE INDEX IF NOT EXISTS idx_pairings_cycle + ON pairings(cycle_id); + +CREATE INDEX IF NOT EXISTS idx_pairings_pair + ON pairings(person_a_id, person_b_id); + + +-- --------------------------------------------------------------------------- +-- 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 + CHECK (touchpoint IN ( + 'opt_in', + 'checkin', + 'survey' + )), + action VARCHAR(30) NOT NULL + CHECK (action IN ( + 'definitely_yes', + 'dont_mind', + 'skip', + 'checkin_yes', + 'checkin_no', + '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 +-- Tracks report generation and delivery to Slack admin channel. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS admin_reports ( + id SERIAL PRIMARY KEY, + cycle_id INTEGER NOT NULL 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() +); + +CREATE INDEX IF NOT EXISTS idx_admin_reports_cycle + ON admin_reports(cycle_id); + +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();