Replaces the two `pricing_*_tiers_json` blobs and five `first_session_discount_*` keys in app_config with dedicated `pricing_tiers` and `pricing_promotions` tables plus matching `_history` audit tables. UUID PKs, UNIQUE(mode, minutes) natural-key constraint, optimistic-lock via `updated_at` token returning 409 STALE_WRITE on conflicts. Every mutation writes a history row capturing the operator (changed_by from request.auth.userId) and change_kind. CC SettingsPage replaces the JSON-textarea editors with per-row tables — add / edit / soft-delete / reactivate / reorder, plus a buffered first-session discount form with the same optimistic-lock contract. `minutes` and `mode` are read-only on edit since they form the natural key; operators soft-delete and recreate to change duration. Stage 5 fixes a latent leak: `client.payment.routes.js` had its own local `readDiscountConfig` that still read from app_config — would have silently fallen to hardcoded defaults once the legacy rows were deleted. Now reads from pricing_promotions via the shared service helper, so CC edits to the first- session discount affect actual payment pricing on the next request. Customer-facing GET /api/client/chat/pricing shape unchanged (id values are now UUIDs instead of "5"/"12"/"60" but lookups happen by (mode, minutes), so no app changes needed). 27 new backend tests, all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
926 lines
34 KiB
JavaScript
926 lines
34 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
|
|
`
|
|
|
|
// 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)
|
|
})
|