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 `__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) })