diff --git a/TECH_DEBT.md b/TECH_DEBT.md new file mode 100644 index 0000000..f0973c4 --- /dev/null +++ b/TECH_DEBT.md @@ -0,0 +1,105 @@ +# Tech Debt + +Running list of known shortcuts, deferred hardening, and "good enough for now" +decisions that need follow-up before they bite us in production. + +Format: `[date]` short title, then enough context for someone (or future-you) +to act on it without re-deriving the discussion. + +--- + +## Backend + +### `[2026-05-11]` Public `GET /api/public/bestie/available` needs rate limiting before prod + +**File:** `backend/src/routes/public/public.bestie-availability.routes.js` + +**Decision:** The endpoint was made unauthenticated by business requirement — +SHome1st renders before any JWT exists, and the CTA must reflect global mitra +availability so users see whether bestie is online before committing to +onboarding. Response is intentionally a single boolean (no count, no IDs). + +**Why it's debt:** No auth + no rate limit. The 10s in-memory cache bounds DB +load, but a single attacker can still hammer the endpoint to: +- run sustained traffic against the public listener (DoS surface) +- scrape `available` over time to infer mitra online/offline patterns (weak + information leak — only "is anyone online", but still a signal) + +**Mitigation before prod:** +- Per-IP rate limit (suggested: ~30 req/min/IP, headroom over the legitimate + 5s client poll cadence = 12 req/min/IP). +- Implement via `@fastify/rate-limit` plugin so other public endpoints can + share the policy as we add them under `/api/public/*`. +- Verify Cloud Run / NLB preserves real client IP and that + `request.ip` reflects it (Fastify already has `trustProxy: true`). + +**Not required:** auth, captcha, or removing the count from +`/api/client/mitra-availability` (that route stays authed for CC/debug). + +--- + +## Client app + +### `[2026-05-11]` Social-login (Google / Apple) has no entry point after S3a rewrite + +**Files:** `client_app/lib/features/auth/screens/register_screen.dart` (no longer renders them); `client_app/lib/core/auth/auth_providers_provider.dart` (still wired). + +**Decision:** `RegisterScreen` was rewritten to match Figma `S3Phone` 1:1 +(step-dots + name greeting + privacy card + tanpa-verif ghost link). Figma +S3a shows no Google/Apple buttons, so they were removed from this screen. + +**Why it's debt:** Google/Apple buttons used to render here when the +`authProvidersProvider` flags were enabled. Today both flags are `false` +(creds pending — see `Phase 3.4 Status` memory), so nothing visible is +missing. But the moment `/api/shared/auth-providers` flips either flag, +the buttons have nowhere to live. + +**Fix-when-creds-arrive:** +- Decide where Google/Apple buttons belong (likely a dedicated login screen + reachable from the SHome1st "masuk →" banner), or whether to bring them + back to S3a as Figma-friendly tiles above the phone input. +- `loginGoogle` / `loginApple` on `authProvider` are still intact, so the + wiring is one button widget away. + +### `[2026-05-12]` Stage 10 — "curhat lagi" entry point lost; Stage 8 Maestro flow broken + +**Files:** `client_app/lib/features/home/widgets/bestie_choice_sheet.dart` (line ~54, now routes to `/chat`); `client_app/.maestro/flows/08_returning_targeted.yaml` (asserts "Riwayat Chat" + "curhat lagi" which no longer render). + +**Decision:** Stage 10 retired `chat_history_screen.dart` and re-pointed the BestieChoiceSheet "bestie yang udah kenal" CTA at `/chat` (which redirects to `/chat/aktif`). The Selesai sub-tab matches Figma `SChatList` — transcript-only, no per-row "curhat lagi" button. + +**Why it's debt:** The "curhat lagi" targeted-payment entry point lived only on the deleted history screen (line 213 `label: 'curhat lagi'` → `context.push('/payment', extra: { 'targetedMitraId': ... })`). After Stage 10 there is **no** UI affordance to start a targeted payment against a known mitra from the customer app — only the general "Mulai Curhat → blast" path. The targeted-payment plumbing in `payment_notifier.dart` / `payment_screen.dart` is now orphaned (still wired, no caller). + +Side-effect: `08_returning_targeted.yaml` (Stage 8) expects to navigate from BestieChoiceSheet → bestie-history list → "curhat lagi" tap → targeted payment. The middle screen is gone; the flow will fail at `assertVisible: "Riwayat Chat"`. + +**Fix options (pick one with product):** +- **A.** Add a "curhat lagi" secondary CTA on Selesai rows (deviates from Figma SChatList but restores the feature). Update `08_returning_targeted.yaml` to navigate `/chat/selesai → tap row's curhat-lagi → targeted payment`. +- **B.** Keep Selesai as transcript-only per Figma; reintroduce a "pick a past bestie" picker reachable from BestieChoiceSheet (essentially restore `chat_history_screen.dart` under a new name + route, kept separate from the Chat tab). +- **C.** Drop the "curhat lagi" feature entirely. Update mermaid + BestieChoiceSheet copy to remove the "bestie yang udah kenal" branch. Delete orphaned targeted-payment plumbing. + +Until decided, `08_returning_targeted.yaml` should be marked `.skip` or the Stage 8 flow rewritten against the new home → SHomeReturning history list (which DOES still render bestie rows via `bestieHistoryProvider`, but those rows tap-through to transcripts only — same "no curhat lagi" gap). + +### `[2026-05-12]` S5 ESP screen retired from spec — code still ships it + +**Files:** `client_app/lib/features/onboarding/` (S5ESP screen + nav wiring); +`screens/onboarding.jsx::S5ESP` (Figma reference still in handoff); any +`espSelectionProvider` / `espSkippedProvider` Riverpod state. + +**Decision:** Business removed the ESP multi-select step from the customer +flow on 2026-05-12. Both verified and anonymous branches now go from +`VerifChoiceSheet` straight to the `usp_seen?` gate. See +`requirement/flow_customer.mermaid.md` §2. + +**Why it's debt:** Stage 2 (commit `2645bcd`) shipped the ESP screen and its +state providers. The screen is still reachable in the current build. The +mermaid spec is the source of truth — the code has drifted behind by one +business decision. + +**Fix:** +- Delete the ESP screen widget and its route registration. +- Remove `espSelectionProvider` / `espSkippedProvider` and any nav step that + routes through ESP. +- Wire `VerifChoiceSheet → USPGate → (USP screen | skip → next)` directly. +- Drop the "ESP is decorative only" memory (it's now superseded by removal). +- Keep `screens/onboarding.jsx::S5ESP` in the Figma handoff folder — it's + history, not active design. + diff --git a/backend/src/routes/internal/_test.routes.js b/backend/src/routes/internal/_test.routes.js index 9b9b578..d15b41d 100644 --- a/backend/src/routes/internal/_test.routes.js +++ b/backend/src/routes/internal/_test.routes.js @@ -245,4 +245,45 @@ export const internalTestRoutes = async (fastify) => { ` return { ok: true, session_id: session.id, mitra_id: mitra.id, mitra_name: mitra.display_name } }) + + // Seed a payment_sessions row in `pending` status for the customer linked + // to `phone`, with expires_at safely in the future. Used by Maestro Stage + // 10 flow (09_chat_tab.yaml) to populate the Pembayaran sub-tab without + // walking the multi-screen S6 paywall → method → duration → method flow. + // + // Body: { phone, isExtension?, amount?, durationMinutes?, mode? } + // - isExtension: defaults false (initial-session payment) + // - amount: defaults 5000 IDR + // - durationMinutes: defaults 15 + // - mode: 'chat' (default) | 'call' + fastify.post('/seed-pending-payment', async (request, reply) => { + const phone = request.body?.phone + if (!phone) { + return reply.code(400).send({ error: 'phone required in body' }) + } + const isExtension = request.body?.isExtension === true + const amount = Number.isFinite(request.body?.amount) ? request.body.amount : 5000 + const durationMinutes = Number.isFinite(request.body?.durationMinutes) + ? request.body.durationMinutes + : 15 + const mode = request.body?.mode === 'call' ? 'call' : 'chat' + + const [customer] = await sql` + SELECT id FROM customers WHERE phone = ${phone} LIMIT 1 + ` + if (!customer) { + return reply.code(404).send({ error: 'no_customer_for_phone', phone }) + } + const [row] = await sql` + INSERT INTO payment_sessions ( + customer_id, amount, duration_minutes, is_first_session_discount, + is_extension, status, mode, expires_at + ) VALUES ( + ${customer.id}, ${amount}, ${durationMinutes}, false, + ${isExtension}, 'pending', ${mode}, NOW() + INTERVAL '20 minutes' + ) + RETURNING id, customer_id, amount, is_extension, status, expires_at + ` + return { ok: true, payment_id: row.id, ...row } + }) } diff --git a/client_app/.maestro/flows/09_chat_tab.yaml b/client_app/.maestro/flows/09_chat_tab.yaml new file mode 100644 index 0000000..52dff6f --- /dev/null +++ b/client_app/.maestro/flows/09_chat_tab.yaml @@ -0,0 +1,161 @@ +# Stage 10 acceptance: Chat tab (3 sub-tabs). +# +# Flow: +# 1. Cold-start onboarding (abbreviated; mirrors 01_smoke) → home with a +# phone-verified customer so the dev seed endpoints can find them. +# 2. Seed a completed chat_sessions row → Selesai sub-tab has data. +# 3. Seed a pending payment_sessions row → Pembayaran sub-tab has data +# AND the bottom-nav chat tab should render a red dot (visual; not +# asserted because Maestro can't reliably introspect small pixel state). +# 4. Tap the "💬 chat" bottom-nav icon → /chat redirects to /chat/aktif. +# 5. Aktif sub-tab: no active session, so empty state copy shows. +# 6. Tap "pembayaran" pill → row with preview "menunggu pembayaran sesi" +# and the "bayar Rp..." chip. +# 7. Tap "selesai" pill → row with seeded mitra name. Tap the row → +# transcript screen opens. +# 8. Back → still on /chat/selesai (URL is source of truth for the sub-tab). +# +# Pre-req: client_app debug APK installed, backend reachable on +# BACKEND_INTERNAL_URL with NODE_ENV != 'production' so /internal/_test/* +# routes are registered, AND at least one mitra is online in the dev DB. +# +# Run: +# maestro test client_app/.maestro/flows/09_chat_tab.yaml +appId: com.halobestie.client.client_app +env: + TEST_PHONE: "+628155556678" + BACKEND_INTERNAL_URL: http://localhost:3001 +--- +# Wipe prior state for TEST_PHONE so the run is hermetic. +- runScript: + file: ../scripts/reset_phone.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- launchApp: + clearState: true + +# Onboarding carousel → splash → home (1st time view). +- extendedWaitUntil: + visible: + text: "Mulai" + timeout: 15000 +- tapOn: + text: "Mulai" + retryTapIfNoChange: true + +# Verif choice sheet → "Lanjut sebagai Tamu" → name → phone OTP +- extendedWaitUntil: + visible: + text: "Lanjut sebagai Tamu" + timeout: 10000 +- tapOn: + text: "Lanjut sebagai Tamu" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Nama panggilan" + timeout: 10000 +- tapOn: + text: "Nama panggilan" +- inputText: "Maestro" +- hideKeyboard +- tapOn: + text: "Lanjut" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Verifikasi Akun" + timeout: 15000 +- tapOn: + text: "Nomor HP" +- inputText: ${TEST_PHONE} +- hideKeyboard +- tapOn: + text: "Kirim OTP" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Masukkan OTP" + timeout: 15000 +- runScript: + file: ../scripts/peek_otp.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- inputText: ${output.OTP} +- extendedWaitUntil: + notVisible: + text: "Masukkan OTP" + timeout: 15000 + +# Returning users land on SHomeReturning with "Mulai Curhat". +- extendedWaitUntil: + visible: + text: "Mulai Curhat" + timeout: 20000 + +# Seed a completed session → Selesai sub-tab populated. +- runScript: + file: ../scripts/seed_history_session.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} + +# Seed a pending payment → Pembayaran sub-tab populated + red dot eligibility. +- runScript: + file: ../scripts/seed_pending_payment.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} + +# Tap "💬 chat" in the bottom nav → /chat → redirected to /chat/aktif. +- tapOn: + text: "chat" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "aktif" + timeout: 10000 +# Sub-tab pills visible; the heading "chat" duplicates with the nav label +# so assert what's unique to the chat-tab shell. +- assertVisible: "aktif" +- assertVisible: "pembayaran" +- assertVisible: "selesai" + +# Aktif default body: empty state since no active session was seeded. +- assertVisible: "belum ada chat di sini" + +# Pembayaran sub-tab → seeded pending initial payment is visible. +- tapOn: + text: "pembayaran" +- extendedWaitUntil: + visible: + text: "menunggu pembayaran sesi" + timeout: 5000 +- assertVisible: + text: "bayar Rp.*" + +# Selesai sub-tab → seeded completed session is visible. +- tapOn: + text: "selesai" +- extendedWaitUntil: + visible: + text: ".* menit" + timeout: 5000 + +# Tap the row → opens the read-only transcript screen. +- tapOn: + text: ".* menit" +- extendedWaitUntil: + visible: + text: "Transkrip Chat" + timeout: 10000 + +# Back returns us to /chat/selesai (URL preserves sub-tab state). +- back +- extendedWaitUntil: + visible: + text: "selesai" + timeout: 5000 +- assertVisible: "selesai" diff --git a/client_app/.maestro/scripts/seed_pending_payment.js b/client_app/.maestro/scripts/seed_pending_payment.js new file mode 100644 index 0000000..30ec5a2 --- /dev/null +++ b/client_app/.maestro/scripts/seed_pending_payment.js @@ -0,0 +1,21 @@ +// Seed a `pending` payment_sessions row for TEST_PHONE so the Pembayaran +// sub-tab has a row to render without walking the multi-screen S6 paywall → +// method-pick → duration-pick → method flow. +// +// Hits the dev-only /internal/_test/seed-pending-payment endpoint. +const phone = TEST_PHONE +const url = BACKEND_INTERNAL_URL || 'http://localhost:3001' +const body = { + phone, + isExtension: PENDING_KIND === 'extension', + amount: PENDING_AMOUNT ? Number(PENDING_AMOUNT) : 5000, +} +const resp = http.post(`${url}/internal/_test/seed-pending-payment`, { + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, +}) +if (resp.status !== 200) { + throw new Error(`seed-pending-payment failed (${resp.status}): ${resp.body}`) +} +const data = json(resp.body) +output.PAYMENT_ID = data.payment_id