From 1de72c40ae86fd9d71bfce0c273187a8f4102c46 Mon Sep 17 00:00:00 2001 From: SashkoMarchuk Date: Mon, 6 Apr 2026 14:58:35 +0700 Subject: [PATCH 1/9] infra(cpb): rewrite DB setup script and add schema for Connecting People Bot Replace the old setup script that required postgres admin access and CREATEROLE with a version that uses temporal user (CREATEDB) and grants to existing n8n user. Add complete 6-table schema (cycles, opt_in_responses, pairings, pair_history, interactions, admin_reports) with idempotent IF NOT EXISTS, triggers, and indexes. Co-Authored-By: Claude Opus 4.6 --- scripts/cpb-setup-db.sh | 80 --------------- scripts/cpb/setup-db.sh | 108 ++++++++++++++++++++ sql/cpb/init-schema.sql | 216 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 324 insertions(+), 80 deletions(-) delete mode 100755 scripts/cpb-setup-db.sh create mode 100755 scripts/cpb/setup-db.sh create mode 100644 sql/cpb/init-schema.sql 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..5be50c9 --- /dev/null +++ b/scripts/cpb/setup-db.sh @@ -0,0 +1,108 @@ +#!/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. +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) && \ + echo " Schema grant on public: OK" || { + 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 +} + +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..5bcba0e --- /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 cpb-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(); From e8ea20ec391053c059206e2b3b56d2531ee0b249 Mon Sep 17 00:00:00 2001 From: SashkoMarchuk Date: Mon, 6 Apr 2026 16:39:24 +0700 Subject: [PATCH 2/9] fix(cpb): correct file paths in documentation comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix two filename references in header comments flagged by CodeRabbit: - setup-db.sh usage example: cpb-setup-db.sh → cpb/setup-db.sh - init-schema.sql usage example: cpb-init-schema.sql → init-schema.sql Co-Authored-By: Claude Opus 4.6 --- scripts/cpb/setup-db.sh | 2 +- sql/cpb/init-schema.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/cpb/setup-db.sh b/scripts/cpb/setup-db.sh index 5be50c9..dafda41 100755 --- a/scripts/cpb/setup-db.sh +++ b/scripts/cpb/setup-db.sh @@ -23,7 +23,7 @@ set -eo pipefail # # Usage: # CPB_POSTGRES_HOST="" POSTGRES_PASSWORD_TEMPORAL="" \ -# POSTGRES_USER_N8N="n8n" ./scripts/cpb-setup-db.sh +# POSTGRES_USER_N8N="n8n" ./scripts/cpb/setup-db.sh # # Required env vars: # CPB_POSTGRES_HOST — PostgreSQL server hostname or IP diff --git a/sql/cpb/init-schema.sql b/sql/cpb/init-schema.sql index 5bcba0e..d3fd21f 100644 --- a/sql/cpb/init-schema.sql +++ b/sql/cpb/init-schema.sql @@ -6,7 +6,7 @@ -- 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 cpb-init-schema.sql +-- 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 From 6a7e67f3eb4a76901cc1037668543b178fb6e987 Mon Sep 17 00:00:00 2001 From: SashkoMarchuk Date: Mon, 6 Apr 2026 17:47:12 +0700 Subject: [PATCH 3/9] fix(cpb): replace A && B || C with explicit if-then-else (SC2015) Refactor schema grant error handling from fragile `cmd && echo || { ... }` to proper if/then/else. Prevents false error-path execution if echo fails. Co-Authored-By: Claude Opus 4.6 --- scripts/cpb/setup-db.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/cpb/setup-db.sh b/scripts/cpb/setup-db.sh index dafda41..a65499f 100755 --- a/scripts/cpb/setup-db.sh +++ b/scripts/cpb/setup-db.sh @@ -85,10 +85,11 @@ EOSQL # 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. -SCHEMA_GRANT_ERR=$(PGPASSWORD="${TEMPORAL_PASS}" psql -v ON_ERROR_STOP=1 \ +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) && \ - echo " Schema grant on public: OK" || { + -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:" @@ -98,7 +99,7 @@ SCHEMA_GRANT_ERR=$(PGPASSWORD="${TEMPORAL_PASS}" psql -v ON_ERROR_STOP=1 \ echo " $SCHEMA_GRANT_ERR" >&2 exit 1 fi -} +fi echo "" echo "CPB database setup complete." From 5edcc5d051e12e3a44f2ffa51ab65084ed47ca61 Mon Sep 17 00:00:00 2001 From: SashkoMarchuk Date: Mon, 6 Apr 2026 23:51:36 +0700 Subject: [PATCH 4/9] infra(cpb): add dedicated role provisioning with master password Replace temporal-based DB setup with a two-script architecture: - create-role.sh: one-time provisioning using RDS master password to create cpb_app role and cpb_bot database - setup-db.sh: repeatable schema setup connecting as cpb_app Co-Authored-By: Claude Opus 4.6 --- .env.example | 4 +- scripts/cpb/create-role.sh | 158 +++++++++++++++++++++++++++++++++++++ scripts/cpb/setup-db.sh | 139 +++++++++++++++++--------------- 3 files changed, 235 insertions(+), 66 deletions(-) create mode 100755 scripts/cpb/create-role.sh 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/create-role.sh b/scripts/cpb/create-role.sh new file mode 100755 index 0000000..5aae2d5 --- /dev/null +++ b/scripts/cpb/create-role.sh @@ -0,0 +1,158 @@ +#!/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 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 index a65499f..6c1a84d 100755 --- a/scripts/cpb/setup-db.sh +++ b/scripts/cpb/setup-db.sh @@ -2,49 +2,47 @@ set -eo pipefail # ============================================================================= -# CPB Production Database Setup +# CPB Schema 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. +# Connects to the cpb_bot database as the cpb_app role and applies the +# init-schema.sql file to create tables, indexes, and triggers. # -# 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 +# Prerequisites: +# - Role cpb_app and database cpb_bot must already exist. +# Run create-role.sh first if they don't. # -# 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) +# Idempotency: +# - Safe to re-run. All DDL uses IF NOT EXISTS / CREATE OR REPLACE. # # Usage: -# CPB_POSTGRES_HOST="" POSTGRES_PASSWORD_TEMPORAL="" \ -# POSTGRES_USER_N8N="n8n" ./scripts/cpb/setup-db.sh +# CPB_POSTGRES_HOST="" POSTGRES_PASSWORD_CPB="" \ +# ./scripts/cpb/setup-db.sh # # Required env vars: -# CPB_POSTGRES_HOST — PostgreSQL server hostname or IP -# POSTGRES_PASSWORD_TEMPORAL — Password for the temporal user +# 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_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) +# 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}" -TEMPORAL_USER="${POSTGRES_USER_TEMPORAL:-temporal}" -N8N_USER="${POSTGRES_USER_N8N:-n8n}" +CPB_USER="${POSTGRES_USER_CPB:-cpb_app}" CPB_DB="${POSTGRES_DB_CPB:-cpb_bot}" -TEMPORAL_PASS="${POSTGRES_PASSWORD_TEMPORAL:?POSTGRES_PASSWORD_TEMPORAL is required}" +# --- 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 PostgreSQL identifiers (prevent SQL injection via crafted names) +# --- Validate identifiers (prevent SQL injection via crafted names) ----------- validate_pg_identifier() { local value="$1" name="$2" if [[ -z "$value" ]]; then @@ -58,52 +56,63 @@ validate_pg_identifier() { fi return 0 } -validate_pg_identifier "$TEMPORAL_USER" "POSTGRES_USER_TEMPORAL" -validate_pg_identifier "$N8N_USER" "POSTGRES_USER_N8N" +validate_pg_identifier "$CPB_USER" "POSTGRES_USER_CPB" 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}" +# --- 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 -# --- 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 +echo "=== CPB Schema Setup ===" +echo " Host: ${PGHOST}:${PGPORT}" +echo " User: ${CPB_USER}" +echo " Database: ${CPB_DB}" +echo " Schema: ${SCHEMA_FILE}" +echo "" --- Grant all database-level privileges to n8n (idempotent) -GRANT ALL PRIVILEGES ON DATABASE "${CPB_DB}" TO "${N8N_USER}"; -EOSQL +# --- Step 1: Verify connectivity --------------------------------------------- +echo "Step 1: Verifying database connection..." +if ! PGPASSWORD="${CPB_PASS}" psql -v ON_ERROR_STOP=1 \ + -h "$PGHOST" -p "$PGPORT" -U "$CPB_USER" -d "$CPB_DB" \ + -c "SELECT 1;" > /dev/null 2>&1; then + echo "ERROR: Cannot connect to ${CPB_DB} as ${CPB_USER}" >&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 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" +# --- 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 - 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 + echo "WARNING: Expected 6 tables, found ${TABLE_COUNT}" >&2 + echo " Run with -v for psql debug output to investigate" >&2 fi echo "" -echo "CPB database setup complete." +echo "=== Schema setup complete ===" echo " Database: ${CPB_DB}" -echo " Owner: ${TEMPORAL_USER}" -echo " Access: ${N8N_USER} (all privileges)" +echo " User: ${CPB_USER}" +echo " Tables: ${TABLE_COUNT}/6" echo " Host: ${PGHOST}:${PGPORT}" From c9c7c2a25104eac43fc000dfb20f73999050e135 Mon Sep 17 00:00:00 2001 From: SashkoMarchuk Date: Tue, 7 Apr 2026 00:43:36 +0700 Subject: [PATCH 5/9] fix(cpb): address CodeRabbit review findings - Add REASSIGN OWNED step in create-role.sh to transfer object ownership (tables, sequences, functions) after database ownership transfer - Fail fast in setup-db.sh when table count != 6 (exit 1, not warning) - Add UNIQUE(cycle_id, person_a_id, person_b_id) to pairings table - Replace independent touchpoint/action CHECKs with composite constraint that enforces valid combinations (e.g. blocks opt_in + satisfied) Co-Authored-By: Claude Opus 4.6 --- scripts/cpb/create-role.sh | 27 +++++++++++++++++++++++++++ scripts/cpb/setup-db.sh | 3 ++- sql/cpb/init-schema.sql | 29 ++++++++++------------------- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/scripts/cpb/create-role.sh b/scripts/cpb/create-role.sh index 5aae2d5..11ddb1c 100755 --- a/scripts/cpb/create-role.sh +++ b/scripts/cpb/create-role.sh @@ -131,6 +131,33 @@ 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 \ diff --git a/scripts/cpb/setup-db.sh b/scripts/cpb/setup-db.sh index 6c1a84d..8c2a8d0 100755 --- a/scripts/cpb/setup-db.sh +++ b/scripts/cpb/setup-db.sh @@ -106,8 +106,9 @@ TABLE_COUNT=$(PGPASSWORD="${CPB_PASS}" psql -v ON_ERROR_STOP=1 -t -A \ if [[ "$TABLE_COUNT" -eq 6 ]]; then echo " All 6 tables verified: OK" else - echo "WARNING: Expected 6 tables, found ${TABLE_COUNT}" >&2 + echo "ERROR: Expected 6 tables, found ${TABLE_COUNT}" >&2 echo " Run with -v for psql debug output to investigate" >&2 + exit 1 fi echo "" diff --git a/sql/cpb/init-schema.sql b/sql/cpb/init-schema.sql index d3fd21f..af43d0c 100644 --- a/sql/cpb/init-schema.sql +++ b/sql/cpb/init-schema.sql @@ -102,7 +102,7 @@ CREATE TRIGGER trg_opt_in_responses_updated_at -- --------------------------------------------------------------------------- -- Table: pairings -- One row per pair per cycle. Canonical ordering enforced: person_a_id < person_b_id. --- Repeats allowed (no UNIQUE on pair columns). +-- Same pair may recur across cycles (repeats tracked via is_repeat flag). -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS pairings ( id SERIAL PRIMARY KEY, @@ -119,7 +119,8 @@ CREATE TABLE IF NOT EXISTS pairings ( 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 + 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_cycle @@ -159,23 +160,13 @@ CREATE TABLE IF NOT EXISTS interactions ( 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' - )), + 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() ); From 0d879efd63f820083ef0c1c22bfce37ca4e71a27 Mon Sep 17 00:00:00 2001 From: SashkoMarchuk Date: Tue, 7 Apr 2026 00:55:49 +0700 Subject: [PATCH 6/9] fix(cpb): add UNIQUE constraint on admin_reports.cycle_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One report per cycle is the correct invariant — enforce at DB level. Co-Authored-By: Claude Opus 4.6 --- sql/cpb/init-schema.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sql/cpb/init-schema.sql b/sql/cpb/init-schema.sql index af43d0c..810ec6d 100644 --- a/sql/cpb/init-schema.sql +++ b/sql/cpb/init-schema.sql @@ -183,11 +183,11 @@ CREATE INDEX IF NOT EXISTS idx_interactions_pairing -- --------------------------------------------------------------------------- -- Table: admin_reports --- Tracks report generation and delivery to Slack admin channel. +-- 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 REFERENCES cycles(id), + 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), From 697fe6b13a40a133c16a43c348d8195c2444e6e6 Mon Sep 17 00:00:00 2001 From: SashkoMarchuk Date: Tue, 7 Apr 2026 10:21:28 +0700 Subject: [PATCH 7/9] fix(cpb): add updated_at to pairings, remove redundant indexes - Add updated_at + trigger to pairings table (6 mutable columns had no modification timestamp, breaking pattern of other mutable tables) - Remove 3 redundant indexes whose leading columns are already covered by UNIQUE constraints (opt_in_responses, pairings, admin_reports) - Fix misleading "-v" hint in setup-db.sh error message Co-Authored-By: Claude Opus 4.6 --- scripts/cpb/setup-db.sh | 2 +- sql/cpb/init-schema.sql | 16 +++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/scripts/cpb/setup-db.sh b/scripts/cpb/setup-db.sh index 8c2a8d0..a9b078f 100755 --- a/scripts/cpb/setup-db.sh +++ b/scripts/cpb/setup-db.sh @@ -107,7 +107,7 @@ if [[ "$TABLE_COUNT" -eq 6 ]]; then echo " All 6 tables verified: OK" else echo "ERROR: Expected 6 tables, found ${TABLE_COUNT}" >&2 - echo " Run with -v for psql debug output to investigate" >&2 + echo " Re-run the psql command manually with -v to investigate" >&2 exit 1 fi diff --git a/sql/cpb/init-schema.sql b/sql/cpb/init-schema.sql index 810ec6d..c2e3df3 100644 --- a/sql/cpb/init-schema.sql +++ b/sql/cpb/init-schema.sql @@ -89,9 +89,6 @@ CREATE TABLE IF NOT EXISTS opt_in_responses ( 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 @@ -119,16 +116,20 @@ CREATE TABLE IF NOT EXISTS pairings ( 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_cycle - ON pairings(cycle_id); - 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 @@ -197,9 +198,6 @@ CREATE TABLE IF NOT EXISTS admin_reports ( 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 From 096023639be66cf557196df030774c559d336ae3 Mon Sep 17 00:00:00 2001 From: SashkoMarchuk Date: Tue, 7 Apr 2026 10:23:26 +0700 Subject: [PATCH 8/9] fix(cpb): add year_month format CHECK constraint on cycles Enforce YYYY-MM format via regex to prevent invalid values like '2026-13' or 'foobar' from being inserted. Flagged independently by two /ultra XL agents (D2-contrarian, V2-schema). Co-Authored-By: Claude Opus 4.6 --- sql/cpb/init-schema.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sql/cpb/init-schema.sql b/sql/cpb/init-schema.sql index c2e3df3..187fdb0 100644 --- a/sql/cpb/init-schema.sql +++ b/sql/cpb/init-schema.sql @@ -34,7 +34,8 @@ $$ LANGUAGE plpgsql; -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS cycles ( id SERIAL PRIMARY KEY, - year_month VARCHAR(7) NOT NULL UNIQUE, -- e.g. '2026-03' + 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, From 5424f1f3904357751f682cc05bd6b4135f48b44a Mon Sep 17 00:00:00 2001 From: SashkoMarchuk Date: Tue, 7 Apr 2026 11:06:30 +0700 Subject: [PATCH 9/9] fix(cpb): preserve psql stderr in connectivity check Capture and display the actual psql error on connection failure instead of suppressing it. Operators now see the real diagnostic (wrong password, host unreachable, DB missing) instead of generic "Cannot connect" message. Co-Authored-By: Claude Opus 4.6 --- scripts/cpb/setup-db.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/cpb/setup-db.sh b/scripts/cpb/setup-db.sh index a9b078f..500eef8 100755 --- a/scripts/cpb/setup-db.sh +++ b/scripts/cpb/setup-db.sh @@ -75,10 +75,11 @@ echo "" # --- Step 1: Verify connectivity --------------------------------------------- echo "Step 1: Verifying database connection..." -if ! PGPASSWORD="${CPB_PASS}" psql -v ON_ERROR_STOP=1 \ +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;" > /dev/null 2>&1; then + -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