Every Xendit invoice now carries metadata: { app: 'halobestie_v2' } so an
external webhook router (no DB access) can fan out v1/v2 traffic purely off
the echoed payload.
Every inbound webhook lands in a new webhook_logs table BEFORE auth or
business logic, so a forensic row survives 401/409/unknown/exception paths.
Primary fields are parsed as columns; raw_body keeps the full payload
verbatim. The handler captures outcome in closure-scoped vars and stamps
http_status/processing_result/processing_error in a single update before
the lone reply.send() — Fastify flushes reply.send() immediately, which
defeated the original finally-block stamp.
A non-UUID external_id no longer crashes the Postgres cast; it ACKs with
ignored_non_uuid_external_id so Xendit stops retrying legacy old-app IDs.
When the DB log itself fails, an optional rolling JSONL file sink absorbs
the event. Disabled by default — opt in via XENDIT_WEBHOOK_FALLBACK_ENABLED.
Naming: <NAME>-YYYY-MM-DD.jsonl in XENDIT_WEBHOOK_FALLBACK_DIR (default
./logs), basename XENDIT_WEBHOOK_FALLBACK_NAME (default
xendit-webhook-fallback). No stdout fallback by design.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1156 lines
44 KiB
JavaScript
1156 lines
44 KiB
JavaScript
import 'dotenv/config'
|
||
import { getDb } from './client.js'
|
||
|
||
const sql = getDb()
|
||
|
||
const migrate = async () => {
|
||
await sql`
|
||
CREATE EXTENSION IF NOT EXISTS "pgcrypto"
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS roles (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
name VARCHAR(100) NOT NULL UNIQUE,
|
||
permissions JSONB NOT NULL DEFAULT '{}',
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS customers (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
firebase_uid VARCHAR(255) UNIQUE,
|
||
phone VARCHAR(20) UNIQUE,
|
||
display_name VARCHAR(100) NOT NULL,
|
||
is_anonymous BOOLEAN NOT NULL DEFAULT TRUE,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS mitras (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
firebase_uid VARCHAR(255) UNIQUE,
|
||
phone VARCHAR(20) NOT NULL UNIQUE,
|
||
display_name VARCHAR(100) NOT NULL,
|
||
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS control_center_users (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
firebase_uid VARCHAR(255) NOT NULL UNIQUE,
|
||
email VARCHAR(255) NOT NULL UNIQUE,
|
||
display_name VARCHAR(100) NOT NULL,
|
||
role_id UUID NOT NULL REFERENCES roles(id),
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS app_config (
|
||
key VARCHAR(100) PRIMARY KEY,
|
||
value JSONB NOT NULL,
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
INSERT INTO app_config (key, value)
|
||
VALUES ('anonymity', '{"enabled": false}')
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
// --- Phase 2: Mitra Online Status & Pairing ---
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS mitra_online_status (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
mitra_id UUID NOT NULL UNIQUE REFERENCES mitras(id),
|
||
is_online BOOLEAN NOT NULL DEFAULT FALSE,
|
||
last_online_at TIMESTAMPTZ,
|
||
last_offline_at TIMESTAMPTZ,
|
||
last_heartbeat_at TIMESTAMPTZ,
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS mitra_online_logs (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
mitra_id UUID NOT NULL REFERENCES mitras(id),
|
||
status VARCHAR(10) NOT NULL,
|
||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_mitra_online_logs_mitra_id
|
||
ON mitra_online_logs (mitra_id)
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS chat_sessions (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
customer_id UUID NOT NULL REFERENCES customers(id),
|
||
mitra_id UUID REFERENCES mitras(id),
|
||
status VARCHAR(30) NOT NULL DEFAULT 'searching',
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
paired_at TIMESTAMPTZ,
|
||
ended_at TIMESTAMPTZ,
|
||
ended_by VARCHAR(20)
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_chat_sessions_customer_id
|
||
ON chat_sessions (customer_id)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_chat_sessions_mitra_id
|
||
ON chat_sessions (mitra_id)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_chat_sessions_status
|
||
ON chat_sessions (status)
|
||
`
|
||
|
||
// Composite index for the per-mitra active-session count subquery used by the
|
||
// 5s availability poll and the per-blast capacity filter.
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_chat_sessions_mitra_status
|
||
ON chat_sessions (mitra_id, status)
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS chat_request_notifications (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
session_id UUID NOT NULL REFERENCES chat_sessions(id),
|
||
mitra_id UUID NOT NULL REFERENCES mitras(id),
|
||
notified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
response VARCHAR(20),
|
||
responded_at TIMESTAMPTZ
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_chat_request_notifications_session_id
|
||
ON chat_request_notifications (session_id)
|
||
`
|
||
|
||
await sql`
|
||
INSERT INTO app_config (key, value)
|
||
VALUES ('max_customers_per_mitra', '{"value": 3}')
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
// --- Phase 3: Chat Engine ---
|
||
|
||
// Add session duration/pricing columns to chat_sessions
|
||
await sql`
|
||
ALTER TABLE chat_sessions
|
||
ADD COLUMN IF NOT EXISTS duration_minutes INT,
|
||
ADD COLUMN IF NOT EXISTS price INT DEFAULT 0,
|
||
ADD COLUMN IF NOT EXISTS is_free_trial BOOLEAN NOT NULL DEFAULT FALSE,
|
||
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ,
|
||
ADD COLUMN IF NOT EXISTS extended_minutes INT NOT NULL DEFAULT 0
|
||
`
|
||
|
||
// Add FCM token columns
|
||
await sql`
|
||
ALTER TABLE customers
|
||
ADD COLUMN IF NOT EXISTS fcm_token VARCHAR(255)
|
||
`
|
||
|
||
await sql`
|
||
ALTER TABLE mitras
|
||
ADD COLUMN IF NOT EXISTS fcm_token VARCHAR(255)
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
session_id UUID NOT NULL REFERENCES chat_sessions(id),
|
||
sender_type VARCHAR(10) NOT NULL,
|
||
sender_id UUID NOT NULL,
|
||
type VARCHAR(20) NOT NULL DEFAULT 'text',
|
||
content TEXT NOT NULL,
|
||
metadata JSONB,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'sent',
|
||
delivered_at TIMESTAMPTZ,
|
||
read_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_created
|
||
ON chat_messages (session_id, created_at)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_status
|
||
ON chat_messages (session_id, status)
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS session_closures (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
session_id UUID NOT NULL REFERENCES chat_sessions(id),
|
||
user_type VARCHAR(10) NOT NULL,
|
||
user_id UUID NOT NULL,
|
||
message TEXT NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS session_extensions (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
session_id UUID NOT NULL REFERENCES chat_sessions(id),
|
||
requested_duration_minutes INT NOT NULL,
|
||
requested_price INT NOT NULL,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
responded_at TIMESTAMPTZ
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS customer_transactions (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
customer_id UUID NOT NULL REFERENCES customers(id),
|
||
session_id UUID NOT NULL REFERENCES chat_sessions(id),
|
||
type VARCHAR(20) NOT NULL,
|
||
amount INT NOT NULL DEFAULT 0,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_customer_transactions_customer_id
|
||
ON customer_transactions (customer_id)
|
||
`
|
||
|
||
// Phase 3 config keys
|
||
await sql`
|
||
INSERT INTO app_config (key, value)
|
||
VALUES ('free_trial_enabled', '{"value": true}')
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
await sql`
|
||
INSERT INTO app_config (key, value)
|
||
VALUES ('free_trial_duration_minutes', '{"value": 5}')
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
await sql`
|
||
INSERT INTO app_config (key, value)
|
||
VALUES ('extension_timeout_seconds', '{"value": 60}')
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
await sql`
|
||
INSERT INTO app_config (key, value)
|
||
VALUES ('early_end_mitra_enabled', '{"value": false}')
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
await sql`
|
||
INSERT INTO app_config (key, value)
|
||
VALUES ('early_end_customer_enabled', '{"value": false}')
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
await sql`
|
||
INSERT INTO app_config (key, value)
|
||
VALUES ('price_tiers', ${sql.json({ tiers: [
|
||
{ duration_minutes: 1, price: 5000, label: '1 Menit (Test)' },
|
||
{ duration_minutes: 15, price: 30000, label: '15 Menit' },
|
||
{ duration_minutes: 30, price: 60000, label: '30 Menit' },
|
||
{ duration_minutes: 45, price: 100000, label: '45 Menit' },
|
||
{ duration_minutes: 60, price: 150000, label: '60 Menit' },
|
||
{ duration_minutes: 1440, price: 250000, label: '24 Jam' },
|
||
]})})
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
// --- Phase 3.1: Mitra Ping Config ---
|
||
|
||
await sql`
|
||
INSERT INTO app_config (key, value)
|
||
VALUES ('require_mitra_ping', '{"value": true}')
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
await sql`
|
||
INSERT INTO app_config (key, value)
|
||
VALUES ('mitra_ping_interval_seconds', '{"value": 15}')
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
// Mitra reachability — replaces the implicit `ping_interval * 3` grace
|
||
// window with an operator-facing "max heartbeat age" knob. The companion
|
||
// heartbeat cadence lives in env (MITRA_HEARTBEAT_CADENCE_SECONDS, default
|
||
// 30s). Default 45s keeps the same effective grace as the old 15s ping × 3.
|
||
// `mitra_ping_interval_seconds` is left in place (vestigial) — no live code
|
||
// path reads it anymore; safe to drop after one release.
|
||
await sql`
|
||
INSERT INTO app_config (key, value)
|
||
VALUES ('mitra_stale_after_seconds', '{"value": 45}')
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
// --- Phase 3.2: Mitra Request Activity Log ---
|
||
|
||
await sql`
|
||
ALTER TABLE chat_request_notifications
|
||
ADD COLUMN IF NOT EXISTS active_session_count INT NOT NULL DEFAULT 0
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_chat_request_notifications_mitra_notified
|
||
ON chat_request_notifications (mitra_id, notified_at)
|
||
`
|
||
|
||
// --- Phase 3.3: Session Topic Sensitivity ---
|
||
|
||
await sql`
|
||
ALTER TABLE chat_sessions
|
||
ADD COLUMN IF NOT EXISTS topic_sensitivity VARCHAR(16) NOT NULL DEFAULT 'regular'
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_chat_sessions_topic_sensitivity
|
||
ON chat_sessions (topic_sensitivity)
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS session_sensitivity_log (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
session_id UUID NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
|
||
changed_by_mitra_id UUID NOT NULL REFERENCES mitras(id),
|
||
from_value VARCHAR(16) NOT NULL,
|
||
to_value VARCHAR(16) NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_session_sensitivity_log_session
|
||
ON session_sensitivity_log (session_id)
|
||
`
|
||
|
||
await sql`
|
||
INSERT INTO app_config (key, value)
|
||
VALUES ('sensitive_flip_confirmation_enabled', '{"value": true}')
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
await sql`
|
||
INSERT INTO app_config (key, value)
|
||
VALUES ('sensitive_flag_one_way_latch', '{"value": false}')
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
// --- Phase 3.4: Self-Managed Auth ---
|
||
|
||
// Customers: add social identity columns
|
||
await sql`
|
||
ALTER TABLE customers
|
||
ADD COLUMN IF NOT EXISTS email VARCHAR(255),
|
||
ADD COLUMN IF NOT EXISTS google_sub VARCHAR(255),
|
||
ADD COLUMN IF NOT EXISTS apple_sub VARCHAR(255)
|
||
`
|
||
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_google_sub ON customers (google_sub) WHERE google_sub IS NOT NULL`
|
||
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_apple_sub ON customers (apple_sub) WHERE apple_sub IS NOT NULL`
|
||
|
||
// display_name is set after sign-in via the set-display-name screen for
|
||
// direct phone/Google/Apple sign-ups (no anonymous bootstrap). Allow null.
|
||
await sql`ALTER TABLE customers ALTER COLUMN display_name DROP NOT NULL`
|
||
|
||
// Control center users: password-based auth columns
|
||
// firebase_uid stays for backward compat during migration; will be dropped in a later cleanup migration
|
||
await sql`ALTER TABLE control_center_users ALTER COLUMN firebase_uid DROP NOT NULL`
|
||
await sql`
|
||
ALTER TABLE control_center_users
|
||
ADD COLUMN IF NOT EXISTS password_hash VARCHAR(60) NOT NULL DEFAULT '',
|
||
ADD COLUMN IF NOT EXISTS failed_login_count INT NOT NULL DEFAULT 0,
|
||
ADD COLUMN IF NOT EXISTS lockout_until TIMESTAMPTZ
|
||
`
|
||
|
||
// Auth sessions (refresh tokens + multi-device)
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
user_type VARCHAR(16) NOT NULL,
|
||
user_id UUID NOT NULL,
|
||
refresh_token_hash VARCHAR(60) NOT NULL,
|
||
device_info JSONB,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
expires_at TIMESTAMPTZ NOT NULL,
|
||
revoked_at TIMESTAMPTZ
|
||
)
|
||
`
|
||
await sql`CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions (user_type, user_id)`
|
||
await sql`CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions (expires_at) WHERE revoked_at IS NULL`
|
||
|
||
// OTP requests (Fazpass reference + rate-limit tracking)
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS otp_requests (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
phone VARCHAR(20) NOT NULL,
|
||
ip_address VARCHAR(45),
|
||
user_type VARCHAR(16) NOT NULL,
|
||
fazpass_reference VARCHAR(255),
|
||
channel VARCHAR(16),
|
||
attempts INT NOT NULL DEFAULT 0,
|
||
used_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
expires_at TIMESTAMPTZ NOT NULL
|
||
)
|
||
`
|
||
await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_phone_created ON otp_requests (phone, created_at)`
|
||
await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_ip_created ON otp_requests (ip_address, created_at)`
|
||
|
||
// Auth-related app_config defaults
|
||
await sql`
|
||
INSERT INTO app_config (key, value) VALUES
|
||
('otp_max_per_phone_per_hour', '{"value": 3}'),
|
||
('otp_max_per_ip_per_hour', '{"value": 10}'),
|
||
('otp_resend_cooldown_seconds', '{"value": 60}'),
|
||
('otp_verify_max_attempts', '{"value": 5}'),
|
||
('cc_login_max_attempts', '{"value": 5}'),
|
||
('cc_login_lockout_minutes', '{"value": 15}')
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
// --- Phase 3.7: Paid Pairing Flow + Returning-Chat + Extension Flip ---
|
||
|
||
// Phase 5 rename — must run BEFORE the original CREATE TABLE so we don't end up
|
||
// with both payment_sessions (old) and payment_requests (newly created from
|
||
// IF NOT EXISTS) coexisting in the same schema. Schema-anchored via
|
||
// current_schema() so the test schema's rename works even after the dev
|
||
// schema already has payment_requests.
|
||
await sql`
|
||
DO $$
|
||
BEGIN
|
||
IF to_regclass(current_schema() || '.payment_sessions') IS NOT NULL
|
||
AND to_regclass(current_schema() || '.payment_requests') IS NULL THEN
|
||
EXECUTE 'ALTER TABLE ' || quote_ident(current_schema()) || '.payment_sessions RENAME TO payment_requests';
|
||
END IF;
|
||
END
|
||
$$
|
||
`
|
||
|
||
// payment_requests: customer-initiated payment intents that gate pairing.
|
||
// (Phase 5 rename — was `payment_sessions` in Phase 3.7. The rename block
|
||
// immediately above + the Phase 5 block at the end of this file together
|
||
// handle every state: fresh DB, pre-Phase-5 dev DB, post-Phase-5 dev DB.)
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS payment_requests (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
customer_id UUID NOT NULL REFERENCES customers(id),
|
||
amount INTEGER NOT NULL DEFAULT 0,
|
||
duration_minutes INTEGER NOT NULL,
|
||
is_free_trial BOOLEAN NOT NULL DEFAULT false,
|
||
is_extension BOOLEAN NOT NULL DEFAULT false,
|
||
status TEXT NOT NULL DEFAULT 'pending'
|
||
CHECK (status IN ('pending','confirmed','consumed','failed_delivery','abandoned','expired','failed')),
|
||
targeted_mitra_id UUID REFERENCES mitras(id),
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
confirmed_at TIMESTAMPTZ,
|
||
consumed_at TIMESTAMPTZ,
|
||
expires_at TIMESTAMPTZ NOT NULL
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_payment_requests_customer
|
||
ON payment_requests (customer_id)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_payment_requests_status_expires
|
||
ON payment_requests (status, expires_at)
|
||
`
|
||
|
||
// pairing_failures: cause-tagged audit rows for confirmed payments that did not yield a chat
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS pairing_failures (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
payment_request_id UUID NOT NULL REFERENCES payment_requests(id) ON DELETE CASCADE,
|
||
customer_id UUID NOT NULL REFERENCES customers(id),
|
||
targeted_mitra_id UUID REFERENCES mitras(id),
|
||
cause_tag TEXT NOT NULL
|
||
CHECK (cause_tag IN (
|
||
'no_mitra_available',
|
||
'all_mitras_rejected',
|
||
'targeted_mitra_offline',
|
||
'targeted_mitra_rejected',
|
||
'targeted_mitra_timeout',
|
||
'payment_request_expired',
|
||
'customer_cancelled'
|
||
)),
|
||
amount INTEGER NOT NULL,
|
||
operator_action TEXT
|
||
CHECK (operator_action IS NULL OR operator_action IN ('refunded','credited','no_action')),
|
||
actioned_by UUID REFERENCES control_center_users(id),
|
||
actioned_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`
|
||
|
||
// Phase 3.7 follow-up: extend the pairing_failures.cause_tag CHECK to include the two
|
||
// extension-specific tags. Idempotent: drop the existing check (whatever its exact name) and
|
||
// re-add the expanded list. Postgres auto-names CHECK constraints `<table>_<column>_check`
|
||
// unless we name them explicitly; the original DDL above relies on that default.
|
||
await sql`
|
||
DO $$
|
||
BEGIN
|
||
IF EXISTS (
|
||
SELECT 1 FROM pg_constraint
|
||
WHERE conrelid = 'pairing_failures'::regclass
|
||
AND conname = 'pairing_failures_cause_tag_check'
|
||
) THEN
|
||
ALTER TABLE pairing_failures DROP CONSTRAINT pairing_failures_cause_tag_check;
|
||
END IF;
|
||
ALTER TABLE pairing_failures ADD CONSTRAINT pairing_failures_cause_tag_check
|
||
CHECK (cause_tag IN (
|
||
'no_mitra_available',
|
||
'all_mitras_rejected',
|
||
'targeted_mitra_offline',
|
||
'targeted_mitra_rejected',
|
||
'targeted_mitra_timeout',
|
||
-- Phase 5 rename: payment_session_expired → payment_request_expired
|
||
'payment_request_expired',
|
||
'customer_cancelled',
|
||
'extension_rejected',
|
||
'extension_safeguard_tripped'
|
||
));
|
||
END
|
||
$$
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_pairing_failures_created_at
|
||
ON pairing_failures (created_at DESC)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_pairing_failures_cause
|
||
ON pairing_failures (cause_tag)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_pairing_failures_unactioned
|
||
ON pairing_failures (created_at DESC) WHERE operator_action IS NULL
|
||
`
|
||
|
||
// chat_sessions FK to payment_requests (nullable for backward compat with pre-3.7 rows)
|
||
await sql`
|
||
ALTER TABLE chat_sessions
|
||
ADD COLUMN IF NOT EXISTS payment_request_id UUID REFERENCES payment_requests(id)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_chat_sessions_payment_request
|
||
ON chat_sessions (payment_request_id)
|
||
`
|
||
|
||
// session_extensions FK to payment_requests (extensions also have their own payment request)
|
||
await sql`
|
||
ALTER TABLE session_extensions
|
||
ADD COLUMN IF NOT EXISTS payment_request_id UUID REFERENCES payment_requests(id)
|
||
`
|
||
|
||
// Phase 3.7 config keys (idempotent — existing dev DBs need a manual update for extension_timeout_seconds → 10)
|
||
await sql`
|
||
INSERT INTO app_config (key, value) VALUES
|
||
('payment_request_timeout_minutes', '{"value": 20}'),
|
||
('returning_chat_confirmation_timeout_seconds', '{"value": 20}'),
|
||
('extension_default_action_on_timeout', '{"value": "auto_approve"}'),
|
||
('pairing_blast_timeout_seconds', '{"value": 60}')
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
// --- Phase 4 — Customer Flow Redesign ---
|
||
|
||
// 1. payment_requests + chat_sessions: replace is_free_trial with is_first_session_discount.
|
||
// Phase 3.7 was the first ship of is_free_trial and never went live with real users
|
||
// (per project memory), so we copy whatever values exist and drop the old column.
|
||
// Idempotent: ADD/DROP both use IF [NOT] EXISTS, and each UPDATE is gated on the
|
||
// old column still existing.
|
||
await sql`
|
||
ALTER TABLE payment_requests
|
||
ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false
|
||
`
|
||
await sql`
|
||
ALTER TABLE chat_sessions
|
||
ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false
|
||
`
|
||
|
||
// Copy values from the legacy column to the new one. We must use dynamic SQL
|
||
// (EXECUTE) inside the DO block — a static reference to is_free_trial would fail
|
||
// to parse when the column has already been dropped on a previous re-run.
|
||
//
|
||
// The IF EXISTS check resolves the column against the *current* search_path so
|
||
// test schemas don't false-positive on the dev `public` schema's leftover columns.
|
||
// We use to_regclass + pg_attribute (which is search_path-aware) instead of
|
||
// information_schema.columns (which lists every schema).
|
||
await sql`
|
||
DO $$
|
||
BEGIN
|
||
IF EXISTS (
|
||
SELECT 1 FROM pg_attribute
|
||
WHERE attrelid = to_regclass('payment_requests')
|
||
AND attname = 'is_free_trial'
|
||
AND NOT attisdropped
|
||
) THEN
|
||
EXECUTE 'UPDATE payment_requests
|
||
SET is_first_session_discount = is_free_trial
|
||
WHERE is_free_trial = true
|
||
AND is_first_session_discount = false';
|
||
END IF;
|
||
IF EXISTS (
|
||
SELECT 1 FROM pg_attribute
|
||
WHERE attrelid = to_regclass('chat_sessions')
|
||
AND attname = 'is_free_trial'
|
||
AND NOT attisdropped
|
||
) THEN
|
||
EXECUTE 'UPDATE chat_sessions
|
||
SET is_first_session_discount = is_free_trial
|
||
WHERE is_free_trial = true
|
||
AND is_first_session_discount = false';
|
||
END IF;
|
||
END
|
||
$$
|
||
`
|
||
|
||
await sql`ALTER TABLE payment_requests DROP COLUMN IF EXISTS is_free_trial`
|
||
await sql`ALTER TABLE chat_sessions DROP COLUMN IF EXISTS is_free_trial`
|
||
|
||
// 2. payment_requests.mode — chat (default) vs voice call. Voice call is just chat
|
||
// with a different price group + a header badge; no extra media handling.
|
||
await sql`
|
||
ALTER TABLE payment_requests
|
||
ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'chat'
|
||
CHECK (mode IN ('chat', 'call'))
|
||
`
|
||
|
||
// 3. chat_sessions.topics — ESP picks persisted for info-only display to mitra.
|
||
// Does NOT affect matching, pricing, or routing.
|
||
await sql`
|
||
ALTER TABLE chat_sessions
|
||
ADD COLUMN IF NOT EXISTS topics TEXT[]
|
||
`
|
||
|
||
// 4. Phase 4 app_config rows. Use ON CONFLICT (key) DO NOTHING so re-runs don't
|
||
// clobber operator edits, and the migration is idempotent against partially
|
||
// populated DBs.
|
||
await sql`
|
||
INSERT INTO app_config (key, value) VALUES
|
||
('payment_method_qris_first', ${sql.json({ value: true })}),
|
||
('searching_timeout_minutes', ${sql.json({ value: 5 })}),
|
||
('end_session_two_step_confirm', ${sql.json({ value: true })}),
|
||
('three_minute_warning_enabled', ${sql.json({ value: true })}),
|
||
('first_session_discount_enabled', ${sql.json({ value: true })}),
|
||
('first_session_discount_actual_price_idr', ${sql.json({ value: 2000 })}),
|
||
('first_session_discount_gimmick_price_idr', ${sql.json({ value: 12000 })}),
|
||
('first_session_discount_duration_minutes', ${sql.json({ value: 12 })}),
|
||
('first_session_discount_modes', ${sql.json({ value: ['chat'] })}),
|
||
('pricing_chat_tiers_json', ${sql.json({ tiers: [
|
||
{ id: '5', minutes: 5, price_idr: 5000, tag: null },
|
||
{ id: '12', minutes: 12, price_idr: 12000, tag: 'paling pas' },
|
||
{ id: '30', minutes: 30, price_idr: 25000, tag: 'hemat' },
|
||
{ id: '60', minutes: 60, price_idr: 45000, tag: null },
|
||
{ id: '120', minutes: 120, price_idr: 80000, tag: 'best deal' },
|
||
]})}),
|
||
('pricing_call_tiers_json', ${sql.json({ tiers: [
|
||
{ id: '10', minutes: 10, price_idr: 9000, tag: null },
|
||
{ id: '20', minutes: 20, price_idr: 17000, tag: 'paling pas' },
|
||
{ id: '45', minutes: 45, price_idr: 35000, tag: null },
|
||
{ id: '60', minutes: 60, price_idr: 45000, tag: 'hemat' },
|
||
]})}),
|
||
('support_handles_json', ${sql.json({
|
||
wa: { label: 'WhatsApp', deeplink: 'https://wa.me/6285173310010' },
|
||
telegram: { label: 'Telegram', deeplink: 'https://t.me/halobestie' },
|
||
})})
|
||
ON CONFLICT (key) DO NOTHING
|
||
`
|
||
|
||
// 5. Phase 4 USP one-time gate. Customers see S5b USP at most once; this flag
|
||
// is the cross-device source of truth, OR-merged with a local
|
||
// SharedPreferences flag on the client. Existing customers come out as
|
||
// false and will see USP one more time on next "aku mau curhat" — business
|
||
// accepted this backfill cost.
|
||
await sql`
|
||
ALTER TABLE customers
|
||
ADD COLUMN IF NOT EXISTS usp_seen BOOLEAN NOT NULL DEFAULT FALSE
|
||
`
|
||
|
||
// --- Phase 4 §2.1: Anonymous → existing-user merge breadcrumb ---
|
||
//
|
||
// When an anonymous customer verifies a phone that already belongs to a
|
||
// different (existing) customer row, we don't 409 the OTP and we don't
|
||
// delete the anon row (which would orphan its chat_sessions /
|
||
// customer_transactions). Instead we stamp account_belongs_to on the anon
|
||
// row pointing at the existing customer's id, then log the app in as the
|
||
// existing user. Actual data reconciliation (moving FKs onto the existing
|
||
// row) is deferred to a later phase — this column is the breadcrumb that
|
||
// makes the merge replayable.
|
||
await sql`
|
||
ALTER TABLE customers
|
||
ADD COLUMN IF NOT EXISTS account_belongs_to UUID REFERENCES customers(id) ON DELETE SET NULL
|
||
`
|
||
|
||
// --- Pricing relational migration — Stage 1 (schema + backfill only) ---
|
||
//
|
||
// Moves the pricing_chat_tiers_json / pricing_call_tiers_json /
|
||
// first_session_discount_* rows from app_config into dedicated relational
|
||
// tables. Stage 1 is schema-only: the live read paths in
|
||
// pricing.service.js continue to read app_config until Stage 3 cuts them
|
||
// over. The seven legacy app_config rows are NOT deleted here — that's
|
||
// Stage 5.
|
||
//
|
||
// Design notes:
|
||
// - PK is UUID (gen_random_uuid()) instead of the doc's TEXT prefix
|
||
// scheme ("chat-60"). Backend lookups go through (mode, minutes),
|
||
// not the id, so the id is purely internal — there is no benefit to
|
||
// a human-readable surrogate key, and UUIDs match the convention
|
||
// used by every other table in this schema.
|
||
// - UNIQUE (mode, minutes) on pricing_tiers and UNIQUE (eligibility)
|
||
// on pricing_promotions enforce the natural keys at the DB level.
|
||
// - History tables reference the live row via tier_id / promotion_id
|
||
// (UUID) — column rename from the doc's `id` to avoid shadowing the
|
||
// history row's own pk.
|
||
// - original_price_idr is shipped as schema-only — not exposed in
|
||
// GET /api/client/pricing in this Stage. Surfacing it to clients is
|
||
// a separate out-of-scope follow-up.
|
||
// - sort_order: operator-controlled ordering distinct from minutes.
|
||
// Backfill seeds it as the array index from the existing JSON so any
|
||
// curated order is preserved.
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS pricing_tiers (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
mode TEXT NOT NULL CHECK (mode IN ('chat', 'call')),
|
||
minutes INTEGER NOT NULL CHECK (minutes > 0),
|
||
price_idr INTEGER NOT NULL CHECK (price_idr >= 0),
|
||
original_price_idr INTEGER CHECK (original_price_idr IS NULL OR original_price_idr >= price_idr),
|
||
tag TEXT,
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
UNIQUE (mode, minutes)
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_pricing_tiers_mode_active_sort
|
||
ON pricing_tiers (mode, is_active, sort_order)
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS pricing_promotions (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||
eligibility TEXT NOT NULL UNIQUE
|
||
CHECK (eligibility IN ('first_session')),
|
||
actual_price_idr INTEGER NOT NULL CHECK (actual_price_idr >= 0),
|
||
gimmick_price_idr INTEGER CHECK (gimmick_price_idr IS NULL OR gimmick_price_idr >= actual_price_idr),
|
||
duration_minutes INTEGER NOT NULL CHECK (duration_minutes > 0),
|
||
modes TEXT[] NOT NULL CHECK (
|
||
array_length(modes, 1) >= 1
|
||
AND modes <@ ARRAY['chat', 'call']::TEXT[]
|
||
),
|
||
starts_at TIMESTAMPTZ,
|
||
ends_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS pricing_tiers_history (
|
||
history_id BIGSERIAL PRIMARY KEY,
|
||
tier_id UUID NOT NULL,
|
||
mode TEXT NOT NULL,
|
||
minutes INTEGER NOT NULL,
|
||
price_idr INTEGER NOT NULL,
|
||
original_price_idr INTEGER,
|
||
tag TEXT,
|
||
sort_order INTEGER NOT NULL,
|
||
is_active BOOLEAN NOT NULL,
|
||
changed_by UUID,
|
||
change_kind TEXT NOT NULL CHECK (change_kind IN ('create', 'update', 'delete')),
|
||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_pricing_tiers_history_tier_time
|
||
ON pricing_tiers_history (tier_id, changed_at DESC)
|
||
`
|
||
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS pricing_promotions_history (
|
||
history_id BIGSERIAL PRIMARY KEY,
|
||
promotion_id UUID NOT NULL,
|
||
enabled BOOLEAN NOT NULL,
|
||
eligibility TEXT NOT NULL,
|
||
actual_price_idr INTEGER NOT NULL,
|
||
gimmick_price_idr INTEGER,
|
||
duration_minutes INTEGER NOT NULL,
|
||
modes TEXT[] NOT NULL,
|
||
starts_at TIMESTAMPTZ,
|
||
ends_at TIMESTAMPTZ,
|
||
changed_by UUID,
|
||
change_kind TEXT NOT NULL CHECK (change_kind IN ('create', 'update', 'delete')),
|
||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
`
|
||
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_pricing_promotions_history_promo_time
|
||
ON pricing_promotions_history (promotion_id, changed_at DESC)
|
||
`
|
||
|
||
// --- Backfill: pricing_tiers ---
|
||
//
|
||
// Only seeds when the table is empty so re-runs don't clobber operator
|
||
// edits or insert duplicates. The unique(mode, minutes) constraint is a
|
||
// belt-and-suspenders backstop in case a half-finished run is retried.
|
||
//
|
||
// Source preference order per mode:
|
||
// 1. app_config.pricing_{chat,call}_tiers_json (if the row exists)
|
||
// 2. Hardcoded defaults that match DEFAULT_{CHAT,CALL}_TIERS in
|
||
// pricing.service.js. We duplicate them here rather than importing
|
||
// because migrate.js is a standalone script.
|
||
|
||
const DEFAULT_CHAT_TIERS_BACKFILL = [
|
||
{ minutes: 5, price_idr: 5000, tag: null },
|
||
{ minutes: 12, price_idr: 12000, tag: 'paling pas' },
|
||
{ minutes: 30, price_idr: 25000, tag: 'hemat' },
|
||
{ minutes: 60, price_idr: 45000, tag: null },
|
||
{ minutes: 120, price_idr: 80000, tag: 'best deal' },
|
||
]
|
||
const DEFAULT_CALL_TIERS_BACKFILL = [
|
||
{ minutes: 10, price_idr: 9000, tag: null },
|
||
{ minutes: 20, price_idr: 17000, tag: 'paling pas' },
|
||
{ minutes: 45, price_idr: 35000, tag: null },
|
||
{ minutes: 60, price_idr: 45000, tag: 'hemat' },
|
||
]
|
||
|
||
const [{ n: tierCount }] = await sql`SELECT COUNT(*)::int AS n FROM pricing_tiers`
|
||
if (tierCount === 0) {
|
||
const [chatRow] = await sql`SELECT value FROM app_config WHERE key = 'pricing_chat_tiers_json'`
|
||
const [callRow] = await sql`SELECT value FROM app_config WHERE key = 'pricing_call_tiers_json'`
|
||
|
||
const chatTiers = Array.isArray(chatRow?.value?.tiers) ? chatRow.value.tiers : DEFAULT_CHAT_TIERS_BACKFILL
|
||
const callTiers = Array.isArray(callRow?.value?.tiers) ? callRow.value.tiers : DEFAULT_CALL_TIERS_BACKFILL
|
||
|
||
for (const [mode, tiers] of [['chat', chatTiers], ['call', callTiers]]) {
|
||
let order = 0
|
||
for (const t of tiers) {
|
||
await sql`
|
||
INSERT INTO pricing_tiers (mode, minutes, price_idr, tag, sort_order, is_active)
|
||
VALUES (
|
||
${mode},
|
||
${t.minutes},
|
||
${t.price_idr},
|
||
${t.tag ?? null},
|
||
${order++},
|
||
true
|
||
)
|
||
ON CONFLICT (mode, minutes) DO NOTHING
|
||
`
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Backfill: pricing_promotions (single 'first_session' row) ---
|
||
//
|
||
// Defaults below match DEFAULT_DISCOUNT in pricing.service.js. The five
|
||
// legacy app_config keys override these if present.
|
||
|
||
const [{ n: promoCount }] = await sql`
|
||
SELECT COUNT(*)::int AS n FROM pricing_promotions WHERE eligibility = 'first_session'
|
||
`
|
||
if (promoCount === 0) {
|
||
const keys = [
|
||
'first_session_discount_enabled',
|
||
'first_session_discount_actual_price_idr',
|
||
'first_session_discount_gimmick_price_idr',
|
||
'first_session_discount_duration_minutes',
|
||
'first_session_discount_modes',
|
||
]
|
||
const rows = await sql`SELECT key, value FROM app_config WHERE key IN ${sql(keys)}`
|
||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||
|
||
const enabled = byKey.first_session_discount_enabled ?? true
|
||
const actual = byKey.first_session_discount_actual_price_idr ?? 2000
|
||
const gimmick = byKey.first_session_discount_gimmick_price_idr ?? 12000
|
||
const duration = byKey.first_session_discount_duration_minutes ?? 12
|
||
const modes = Array.isArray(byKey.first_session_discount_modes)
|
||
? byKey.first_session_discount_modes
|
||
: ['chat']
|
||
|
||
await sql`
|
||
INSERT INTO pricing_promotions (
|
||
enabled, eligibility, actual_price_idr, gimmick_price_idr, duration_minutes, modes
|
||
)
|
||
VALUES (
|
||
${enabled},
|
||
'first_session',
|
||
${actual},
|
||
${gimmick},
|
||
${duration},
|
||
${modes}
|
||
)
|
||
ON CONFLICT (eligibility) DO NOTHING
|
||
`
|
||
}
|
||
|
||
// --- Pricing relational migration — Stage 5 cleanup ---
|
||
//
|
||
// The seven legacy `app_config` rows below were the JSON-on-app_config
|
||
// source of truth for pricing tiers and the first-session discount, copied
|
||
// into `pricing_tiers` / `pricing_promotions` by the Stage 1 backfill above.
|
||
// Stage 3 cut every read path over to the relational tables; this delete
|
||
// removes the now-orphaned rows so operator edits in CC can't get out of
|
||
// sync with the live source.
|
||
//
|
||
// Idempotent: a fresh dev DB just deletes zero rows. A previously-migrated
|
||
// DB on this revision is a no-op. The seed of these keys in the Phase 4
|
||
// app_config INSERT block above (~line 627) uses ON CONFLICT (key) DO
|
||
// NOTHING — so even if the seed runs *after* this delete during a future
|
||
// refactor, we don't accidentally resurrect them on the next pass.
|
||
await sql`
|
||
DELETE FROM app_config
|
||
WHERE key IN (
|
||
'first_session_discount_enabled',
|
||
'first_session_discount_actual_price_idr',
|
||
'first_session_discount_gimmick_price_idr',
|
||
'first_session_discount_duration_minutes',
|
||
'first_session_discount_modes',
|
||
'pricing_chat_tiers_json',
|
||
'pricing_call_tiers_json'
|
||
)
|
||
`
|
||
|
||
// --- Phase 5 — Payment service rename + Xendit prep ---
|
||
//
|
||
// The `payment_sessions` table is renamed to `payment_requests` so future
|
||
// products (courses, merch, subscriptions) can reuse the same payment layer
|
||
// without the chat-coupled name. See requirement/phase5-xendit-plan.md
|
||
// Architecture (revised 2026-05-23) for the full rationale.
|
||
//
|
||
// The migration is idempotent in both directions: on fresh DBs the
|
||
// earlier CREATE TABLE block builds `payment_sessions`, this block then
|
||
// renames it. On already-migrated DBs the rename is a no-op.
|
||
|
||
// 1. Rename the table if it still has the old name.
|
||
// Schema-anchored via current_schema() so the rename works correctly when
|
||
// migrate runs against a non-public schema (test DBs use search_path).
|
||
// Without the schema qualifier, to_regclass('payment_requests') would
|
||
// fall through to public.payment_requests (after the dev schema migrated)
|
||
// and the test schema's rename would be skipped.
|
||
await sql`
|
||
DO $$
|
||
BEGIN
|
||
IF to_regclass(current_schema() || '.payment_sessions') IS NOT NULL
|
||
AND to_regclass(current_schema() || '.payment_requests') IS NULL THEN
|
||
EXECUTE 'ALTER TABLE ' || quote_ident(current_schema()) || '.payment_sessions RENAME TO payment_requests';
|
||
END IF;
|
||
END
|
||
$$
|
||
`
|
||
|
||
// 2. Rename indexes Postgres auto-named after the old table
|
||
await sql`ALTER INDEX IF EXISTS idx_payment_sessions_customer RENAME TO idx_payment_requests_customer`
|
||
await sql`ALTER INDEX IF EXISTS idx_payment_sessions_status_expires RENAME TO idx_payment_requests_status_expires`
|
||
// Primary-key index doesn't get auto-renamed by ALTER TABLE ... RENAME.
|
||
// Fix it on existing dev DBs so a future reader doesn't see payment_sessions_pkey on the payment_requests table.
|
||
await sql`ALTER INDEX IF EXISTS payment_sessions_pkey RENAME TO payment_requests_pkey`
|
||
|
||
// 3. Rename FK columns on dependent tables (schema-anchored for the same reason as above)
|
||
await sql`
|
||
DO $$
|
||
DECLARE
|
||
schema_name TEXT := current_schema();
|
||
BEGIN
|
||
IF EXISTS (SELECT 1 FROM pg_attribute
|
||
WHERE attrelid = to_regclass(schema_name || '.chat_sessions')
|
||
AND attname = 'payment_session_id'
|
||
AND NOT attisdropped) THEN
|
||
EXECUTE 'ALTER TABLE ' || quote_ident(schema_name) || '.chat_sessions RENAME COLUMN payment_session_id TO payment_request_id';
|
||
END IF;
|
||
IF EXISTS (SELECT 1 FROM pg_attribute
|
||
WHERE attrelid = to_regclass(schema_name || '.session_extensions')
|
||
AND attname = 'payment_session_id'
|
||
AND NOT attisdropped) THEN
|
||
EXECUTE 'ALTER TABLE ' || quote_ident(schema_name) || '.session_extensions RENAME COLUMN payment_session_id TO payment_request_id';
|
||
END IF;
|
||
IF EXISTS (SELECT 1 FROM pg_attribute
|
||
WHERE attrelid = to_regclass(schema_name || '.pairing_failures')
|
||
AND attname = 'payment_session_id'
|
||
AND NOT attisdropped) THEN
|
||
EXECUTE 'ALTER TABLE ' || quote_ident(schema_name) || '.pairing_failures RENAME COLUMN payment_session_id TO payment_request_id';
|
||
END IF;
|
||
END
|
||
$$
|
||
`
|
||
await sql`ALTER INDEX IF EXISTS idx_chat_sessions_payment RENAME TO idx_chat_sessions_payment_request`
|
||
|
||
// 4. Rename the app_config key. Skip if both rows exist (we'd violate the PK).
|
||
await sql`
|
||
UPDATE app_config
|
||
SET key = 'payment_request_timeout_minutes'
|
||
WHERE key = 'payment_session_timeout_minutes'
|
||
AND NOT EXISTS (SELECT 1 FROM app_config WHERE key = 'payment_request_timeout_minutes')
|
||
`
|
||
|
||
// 5. Status enum rewrite: rename failed_pairing → failed_delivery (product-agnostic),
|
||
// add 'failed' (createInvoice errored before customer paid — distinct from expired).
|
||
// Drop both the legacy and current constraint names so re-runs are idempotent.
|
||
await sql`ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_sessions_status_check`
|
||
await sql`ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_requests_status_check`
|
||
await sql`UPDATE payment_requests SET status = 'failed_delivery' WHERE status = 'failed_pairing'`
|
||
await sql`
|
||
ALTER TABLE payment_requests
|
||
ADD CONSTRAINT payment_requests_status_check
|
||
CHECK (status IN ('pending','confirmed','consumed','expired','abandoned','failed','failed_delivery'))
|
||
`
|
||
|
||
// 6. cause_tag rewrite on pairing_failures — payment_session_expired → payment_request_expired
|
||
await sql`ALTER TABLE pairing_failures DROP CONSTRAINT IF EXISTS pairing_failures_cause_tag_check`
|
||
await sql`UPDATE pairing_failures SET cause_tag = 'payment_request_expired' WHERE cause_tag = 'payment_session_expired'`
|
||
await sql`
|
||
ALTER TABLE pairing_failures
|
||
ADD CONSTRAINT pairing_failures_cause_tag_check
|
||
CHECK (cause_tag IN (
|
||
'no_mitra_available',
|
||
'all_mitras_rejected',
|
||
'targeted_mitra_offline',
|
||
'targeted_mitra_rejected',
|
||
'targeted_mitra_timeout',
|
||
'payment_request_expired',
|
||
'customer_cancelled',
|
||
'extension_rejected',
|
||
'extension_safeguard_tripped'
|
||
))
|
||
`
|
||
|
||
// 7. Add product-agnostic columns (microservice-prep) + xendit_* columns.
|
||
// product_type defaults to 'chat_session' so every existing row is
|
||
// self-describing without a manual backfill.
|
||
await sql`
|
||
ALTER TABLE payment_requests
|
||
ADD COLUMN IF NOT EXISTS product_type TEXT NOT NULL DEFAULT 'chat_session',
|
||
ADD COLUMN IF NOT EXISTS product_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||
ADD COLUMN IF NOT EXISTS xendit_invoice_id TEXT,
|
||
ADD COLUMN IF NOT EXISTS xendit_invoice_url TEXT,
|
||
ADD COLUMN IF NOT EXISTS xendit_payment_method TEXT,
|
||
ADD COLUMN IF NOT EXISTS xendit_paid_amount INTEGER
|
||
`
|
||
|
||
// 8. Partial unique index on xendit_invoice_id — webhook retries land on the same
|
||
// invoice id, this turns "already processed" into a constraint violation our
|
||
// handler can detect.
|
||
await sql`
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_requests_xendit_invoice
|
||
ON payment_requests (xendit_invoice_id)
|
||
WHERE xendit_invoice_id IS NOT NULL
|
||
`
|
||
|
||
// 9. Backfill product_metadata for pre-Phase-5 rows so subscribers can read
|
||
// chat-session details without falling back to the legacy top-level columns.
|
||
await sql`
|
||
UPDATE payment_requests
|
||
SET product_metadata = jsonb_build_object(
|
||
'duration_minutes', duration_minutes,
|
||
'mode', mode,
|
||
'is_extension', is_extension,
|
||
'targeted_mitra_id', targeted_mitra_id
|
||
)
|
||
WHERE product_metadata = '{}'::jsonb
|
||
AND product_type = 'chat_session'
|
||
`
|
||
|
||
// 10. webhook_logs — survival/audit table for every inbound payment-provider
|
||
// webhook. The route handler inserts a row BEFORE auth checks or business
|
||
// logic so a forensic record exists even when the request is rejected,
|
||
// the body is malformed, or processing throws.
|
||
//
|
||
// Primary fields are extracted as columns (queryable in CC, indexed where
|
||
// useful); the full body is kept in `raw_body` JSONB so we can replay or
|
||
// diff later. `provider` is a string column (not enum) so adding a new
|
||
// payment provider doesn't require a migration.
|
||
//
|
||
// No FK to payment_requests — logs must survive even if the matching
|
||
// payment row was wiped, never existed, or arrives for a product/event
|
||
// type we don't yet model.
|
||
await sql`
|
||
CREATE TABLE IF NOT EXISTS webhook_logs (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
provider TEXT NOT NULL DEFAULT 'xendit',
|
||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
||
-- Parsed primary fields (Xendit Invoice callback shape — NULL for other event types)
|
||
xendit_event_id TEXT,
|
||
external_id TEXT,
|
||
payment_request_id UUID,
|
||
status TEXT,
|
||
amount BIGINT,
|
||
currency TEXT,
|
||
payment_method TEXT,
|
||
paid_at TIMESTAMPTZ,
|
||
metadata_app TEXT,
|
||
|
||
-- Integrity + verbatim record
|
||
callback_token_valid BOOLEAN NOT NULL,
|
||
headers JSONB NOT NULL,
|
||
raw_body JSONB NOT NULL,
|
||
|
||
-- Outcome (filled by the handler after processing). Leaving these NULL
|
||
-- is itself a useful signal — it means the handler crashed before the
|
||
-- finally block could stamp the result.
|
||
http_status SMALLINT,
|
||
processing_result TEXT,
|
||
processing_error TEXT,
|
||
processed_at TIMESTAMPTZ
|
||
)
|
||
`
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_webhook_logs_received_at
|
||
ON webhook_logs (received_at DESC)
|
||
`
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_webhook_logs_external_id
|
||
ON webhook_logs (external_id)
|
||
WHERE external_id IS NOT NULL
|
||
`
|
||
await sql`
|
||
CREATE INDEX IF NOT EXISTS idx_webhook_logs_payment_request
|
||
ON webhook_logs (payment_request_id)
|
||
WHERE payment_request_id IS NOT NULL
|
||
`
|
||
|
||
console.log('Migration complete.')
|
||
await sql.end()
|
||
}
|
||
|
||
migrate().catch((err) => {
|
||
console.error('Migration failed:', err)
|
||
process.exit(1)
|
||
})
|