Phase 3.7: paid pairing flow + returning chat + extension flip

- Backend: payment_sessions + pairing_failures tables; payment.service.js
  and pairing-failure.service.js (new); rewritten pairing.service.js
  (payment-gated blast + targeted "Curhat lagi" + cancel + fallback);
  rewritten extension.service.js (data-driven auto-approve with offline
  safeguard, charge-at-approval); pricing.service.js (extension tiers
  without free trial); mitra-status.service.js (countAvailableMitras
  cached path); 60s sweeper for stale payment sessions
- Backend routes: client.payment.routes, client.mitra-availability.routes,
  internal/failed-pairings.routes; client.chat.routes rewritten for
  payment-gated start + /returning + /cancel + /fallback-to-blast;
  internal/config.routes adds 4 new keys with Valkey invalidate publish
- client_app: mitra-availability poll, payment screen + notifier, pairing
  notifier rewrite (PairingTargetedWaiting + PairingFailed states),
  targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi"
  CTA, failed-pairing terminal, extension via payment-session
- mitra_app: PairingRequestType enum, returning-chat 20s countdown
  auto-dismiss, extension card "otomatis disetujui" copy
- control_center: 4 new config rows in Settings, Failed Pairings page
  (filter + paginate + action menu), sidebar + route registered
- Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4
  pass), Maestro mobile scaffold (CLI install pending)
- Bugs found via Playwright + fixed: LoginPage labels not associated
  with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE
  in allow-methods (silent settings breakage in browsers since Stage 4)
- Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A),
  phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md
  (today's run results)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 23:02:49 +08:00
parent f3766813f3
commit d09e50af55
92 changed files with 9579 additions and 437 deletions

View File

@@ -120,6 +120,13 @@ const migrate = async () => {
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(),
@@ -413,6 +420,135 @@ const migrate = async () => {
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
`
console.log('Migration complete.')
await sql.end()
}