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 ` // --- 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 --- // payment_sessions: customer-initiated payment intents (mocked) that gate pairing await sql` CREATE TABLE IF NOT EXISTS payment_sessions ( 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_pairing','abandoned','expired')), 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_sessions_customer ON payment_sessions (customer_id) ` await sql` CREATE INDEX IF NOT EXISTS idx_payment_sessions_status_expires ON payment_sessions (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_session_id UUID NOT NULL REFERENCES payment_sessions(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_session_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', 'payment_session_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_sessions (nullable for backward compat with pre-3.7 rows) await sql` ALTER TABLE chat_sessions ADD COLUMN IF NOT EXISTS payment_session_id UUID REFERENCES payment_sessions(id) ` await sql` CREATE INDEX IF NOT EXISTS idx_chat_sessions_payment ON chat_sessions (payment_session_id) ` // session_extensions FK to payment_sessions (extensions also have their own payment session) await sql` ALTER TABLE session_extensions ADD COLUMN IF NOT EXISTS payment_session_id UUID REFERENCES payment_sessions(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_session_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_sessions + 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_sessions 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_sessions') AND attname = 'is_free_trial' AND NOT attisdropped ) THEN EXECUTE 'UPDATE payment_sessions 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_sessions DROP COLUMN IF EXISTS is_free_trial` await sql`ALTER TABLE chat_sessions DROP COLUMN IF EXISTS is_free_trial` // 2. payment_sessions.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_sessions 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' ) ` console.log('Migration complete.') await sql.end() } migrate().catch((err) => { console.error('Migration failed:', err) process.exit(1) })