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>
665 lines
23 KiB
JavaScript
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)
|
|
})
|