Files
halobestie-clone/backend/src/db/migrate.js
ramadhan sjamsani d33d4419ea Phase 4 Stage 1: backend foundation (additive endpoints + schema)
Schema (idempotent migration):
- payment_sessions.is_free_trial -> is_first_session_discount (data copied)
- payment_sessions.mode TEXT NOT NULL DEFAULT 'chat' CHECK (chat|call)
- chat_sessions.topics TEXT[] for ESP picks (info-only)

New endpoints:
- GET /api/client/onboarding-state (drives verif sheet + S6 paywall gate)
- GET /api/client/chat-pricing (rewrite: chat+call groups + first-session
  discount block, per-customer eligibility)
- GET /api/shared/auth-providers (env-probed; replaces ENABLE_SOCIAL_AUTH
  build flag — frontend cutover lands in stage 2)
- GET /api/client/support-handles (Tanya Admin handles, CC-config-driven)

session_warning WS event fires once at 180s remaining.

app_config seeds (mock pricing tiers, first-session discount, support
handles, payment method order, end-session 2-step toggle).

CC SettingsPage: 3 new sections (first-session discount, pricing tiers
JSON editors, support handles).

15/15 Vitest passing. chat_sessions.is_free_trial also renamed for
consistency (plan only specified payment_sessions; pairing.service.js
read both).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:56:28 +08:00

665 lines
23 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
`
// --- 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 `<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',
'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
`
console.log('Migration complete.')
await sql.end()
}
migrate().catch((err) => {
console.error('Migration failed:', err)
process.exit(1)
})