From d668112eddbec3791cdf16680ce29392be8cee0a Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Sun, 5 Apr 2026 23:17:49 +0800 Subject: [PATCH] Phase 2 scaffold: mitra online status & pairing logic Add mitra online/offline status with heartbeat-based auto-offline, customer-mitra pairing via Valkey pub/sub blast, session management, and control center dashboard with real-time stats. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 10 +- backend/.env.example | 3 + backend/package.json | 1 + backend/src/app.internal.js | 2 + backend/src/app.public.js | 6 + backend/src/db/migrate.js | 78 +++++ backend/src/plugins/valkey.js | 47 +++ backend/src/routes/internal/config.routes.js | 20 +- backend/src/routes/internal/mitra.routes.js | 16 + backend/src/routes/internal/session.routes.js | 48 +++ .../src/routes/public/client.chat.routes.js | 75 ++++ .../src/routes/public/mitra.chat.routes.js | 69 ++++ .../src/routes/public/mitra.status.routes.js | 43 +++ backend/src/server.js | 11 + backend/src/services/config.service.js | 14 + backend/src/services/dashboard.service.js | 28 ++ backend/src/services/mitra-status.service.js | 107 ++++++ backend/src/services/pairing.service.js | 247 ++++++++++++++ backend/src/services/session.service.js | 149 ++++++++ client_app/lib/core/api/api_client.dart | 4 +- client_app/lib/core/pairing/pairing_bloc.dart | 173 ++++++++++ .../chat/screens/bestie_found_screen.dart | 52 +++ .../chat/screens/no_bestie_screen.dart | 39 +++ .../chat/screens/searching_screen.dart | 55 +++ .../chat/screens/session_active_screen.dart | 82 +++++ client_app/lib/features/home/home_screen.dart | 79 +++-- client_app/lib/main.dart | 10 +- client_app/lib/router.dart | 19 ++ control_center/src/App.jsx | 6 +- control_center/src/components/Layout.jsx | 2 + .../src/pages/dashboard/DashboardPage.jsx | 60 ++++ .../src/pages/mitras/MitrasPage.jsx | 98 +++++- .../src/pages/sessions/SessionsPage.jsx | 131 +++++++ .../src/pages/settings/SettingsPage.jsx | 50 ++- mitra_app/lib/core/api/api_client.dart | 9 +- .../lib/core/chat/chat_request_bloc.dart | 165 +++++++++ mitra_app/lib/core/status/status_bloc.dart | 129 +++++++ .../chat/screens/active_sessions_screen.dart | 88 +++++ .../chat/widgets/incoming_request_sheet.dart | 55 +++ mitra_app/lib/features/home/home_screen.dart | 150 +++++++- mitra_app/lib/main.dart | 67 +++- mitra_app/lib/router.dart | 2 + requirement/phase2-plan.md | 322 ++++++++++++++++++ requirement/phase2.md | 59 ++++ 44 files changed, 2800 insertions(+), 80 deletions(-) create mode 100644 backend/src/plugins/valkey.js create mode 100644 backend/src/routes/internal/session.routes.js create mode 100644 backend/src/routes/public/client.chat.routes.js create mode 100644 backend/src/routes/public/mitra.chat.routes.js create mode 100644 backend/src/routes/public/mitra.status.routes.js create mode 100644 backend/src/services/dashboard.service.js create mode 100644 backend/src/services/mitra-status.service.js create mode 100644 backend/src/services/pairing.service.js create mode 100644 backend/src/services/session.service.js create mode 100644 client_app/lib/core/pairing/pairing_bloc.dart create mode 100644 client_app/lib/features/chat/screens/bestie_found_screen.dart create mode 100644 client_app/lib/features/chat/screens/no_bestie_screen.dart create mode 100644 client_app/lib/features/chat/screens/searching_screen.dart create mode 100644 client_app/lib/features/chat/screens/session_active_screen.dart create mode 100644 control_center/src/pages/dashboard/DashboardPage.jsx create mode 100644 control_center/src/pages/sessions/SessionsPage.jsx create mode 100644 mitra_app/lib/core/chat/chat_request_bloc.dart create mode 100644 mitra_app/lib/core/status/status_bloc.dart create mode 100644 mitra_app/lib/features/chat/screens/active_sessions_screen.dart create mode 100644 mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart create mode 100644 requirement/phase2-plan.md create mode 100644 requirement/phase2.md diff --git a/CLAUDE.md b/CLAUDE.md index 02cd5c3..e5e3f61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,8 @@ Mental health chat platform connecting clients (users seeking support) with trai - **Control center is internal-only** — never expose its API routes to the public internet; protected via Nginx allow/deny + VPN - **Firebase Auth** tokens are verified on Fastify via JWT — user data lives in PostgreSQL, linked by Firebase UID - **Horizontal scaling** (Cloud Run) handles load — do not split into microservices prematurely -- **Real-time features** (chat) are deferred to requirements phase +- **Real-time features** use Valkey pub/sub for in-app events; FCM push notifications planned for next phase +- **Pairing** uses blast-to-all-available-mitras with first-come-first-served acceptance ## Current Progress @@ -40,7 +41,12 @@ Mental health chat platform connecting clients (users seeking support) with trai - mitra_app: OTP-only login - control_center: email/password login, mitra management, user management, anonymity settings - Docs: `requirement/phase1-plan.md`, `requirement/phase1-api-contract.md`, `requirement/client_app_mockup.html` -- **Phase 2 (Sessions, Chat, Payments)** — not yet started, requirements not yet written +- **Phase 2 (Mitra Online Status & Pairing)** — fully scaffolded + - Backend: Valkey pub/sub, mitra online/offline status + heartbeat + auto-offline, pairing service, session management, dashboard stats + - client_app: "Mulai Curhat" CTA, searching/found/no-bestie/session-active screens, PairingBloc + - mitra_app: online/offline toggle, heartbeat + lifecycle handling, incoming request notification, active sessions screen + - control_center: dashboard (auto-refresh), max customers per mitra config, session management + reroute, mitra online logs + - Docs: `requirement/phase2.md`, `requirement/phase2-plan.md` ## Domain Concepts diff --git a/backend/.env.example b/backend/.env.example index d560d97..293ff44 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,6 +6,9 @@ INTERNAL_HOST=127.0.0.1 # Database DATABASE_URL=postgresql://user:password@localhost:5432/halobestie +# Valkey / Redis +VALKEY_URL=redis://localhost:6379 + # Firebase FIREBASE_PROJECT_ID=your-firebase-project-id FIREBASE_CLIENT_EMAIL=your-service-account@project.iam.gserviceaccount.com diff --git a/backend/package.json b/backend/package.json index 24aa0e0..c586cb9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "fastify": "^4.28.1", "@fastify/sensible": "^5.6.0", "firebase-admin": "^12.2.0", + "ioredis": "^5.4.1", "pg": "^8.12.0", "postgres": "^3.4.4", "zod": "^3.23.8", diff --git a/backend/src/app.internal.js b/backend/src/app.internal.js index 8b93e8e..7861b51 100644 --- a/backend/src/app.internal.js +++ b/backend/src/app.internal.js @@ -5,6 +5,7 @@ import { ccUserRoutes } from './routes/internal/cc-user.routes.js' import { rolesRoutes } from './routes/internal/roles.routes.js' import { internalAuthRoutes } from './routes/internal/auth.routes.js' import { internalConfigRoutes } from './routes/internal/config.routes.js' +import { sessionManagementRoutes } from './routes/internal/session.routes.js' import { errorHandler } from './plugins/error-handler.js' export const buildInternalApp = async () => { @@ -18,6 +19,7 @@ export const buildInternalApp = async () => { app.register(ccUserRoutes, { prefix: '/internal/control-center-users' }) app.register(rolesRoutes, { prefix: '/internal/roles' }) app.register(internalConfigRoutes, { prefix: '/internal/config' }) + app.register(sessionManagementRoutes, { prefix: '/internal/sessions' }) return app } diff --git a/backend/src/app.public.js b/backend/src/app.public.js index 5d3c1b0..7958aba 100644 --- a/backend/src/app.public.js +++ b/backend/src/app.public.js @@ -4,6 +4,9 @@ import { customerRoutes } from './routes/public/customer.routes.js' import { clientAuthRoutes } from './routes/public/client.auth.routes.js' import { mitraAuthRoutes } from './routes/public/mitra.auth.routes.js' import { sharedConfigRoutes } from './routes/public/shared.config.routes.js' +import { mitraStatusRoutes } from './routes/public/mitra.status.routes.js' +import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js' +import { clientChatRoutes } from './routes/public/client.chat.routes.js' import { errorHandler } from './plugins/error-handler.js' export const buildPublicApp = async () => { @@ -16,6 +19,9 @@ export const buildPublicApp = async () => { app.register(sharedConfigRoutes, { prefix: '/api/shared/config' }) app.register(clientAuthRoutes, { prefix: '/api/client/auth' }) app.register(mitraAuthRoutes, { prefix: '/api/mitra/auth' }) + app.register(mitraStatusRoutes, { prefix: '/api/mitra/status' }) + app.register(mitraChatRoutes, { prefix: '/api/mitra/chat-requests' }) + app.register(clientChatRoutes, { prefix: '/api/client/chat' }) return app } diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index cc9d868..321af5f 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -64,6 +64,84 @@ const migrate = async () => { 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) + ` + + 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 + ` + console.log('Migration complete.') await sql.end() } diff --git a/backend/src/plugins/valkey.js b/backend/src/plugins/valkey.js new file mode 100644 index 0000000..6918892 --- /dev/null +++ b/backend/src/plugins/valkey.js @@ -0,0 +1,47 @@ +import Redis from 'ioredis' + +let pub +let sub +let client + +export const getValkeyClient = () => { + if (!client) { + const url = process.env.VALKEY_URL || 'redis://localhost:6379' + client = new Redis(url) + } + return client +} + +export const getValkeyPub = () => { + if (!pub) { + const url = process.env.VALKEY_URL || 'redis://localhost:6379' + pub = new Redis(url) + } + return pub +} + +export const getValkeySub = () => { + if (!sub) { + const url = process.env.VALKEY_URL || 'redis://localhost:6379' + sub = new Redis(url) + } + return sub +} + +export const publish = async (channel, data) => { + const pubClient = getValkeyPub() + await pubClient.publish(channel, JSON.stringify(data)) +} + +export const subscribe = (channel, callback) => { + const subClient = getValkeySub() + subClient.subscribe(channel) + subClient.on('message', (ch, message) => { + if (ch === channel) { + callback(JSON.parse(message)) + } + }) + return () => { + subClient.unsubscribe(channel) + } +} diff --git a/backend/src/routes/internal/config.routes.js b/backend/src/routes/internal/config.routes.js index 10ea6d4..f9a58df 100644 --- a/backend/src/routes/internal/config.routes.js +++ b/backend/src/routes/internal/config.routes.js @@ -1,6 +1,6 @@ import { authenticate, requirePermission } from '../../plugins/auth.js' import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js' -import { getAnonymityConfig, setAnonymityConfig } from '../../services/config.service.js' +import { getAnonymityConfig, setAnonymityConfig, getMaxCustomersPerMitra, setMaxCustomersPerMitra } from '../../services/config.service.js' const attachCcUser = async (request, reply) => { const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) @@ -26,4 +26,22 @@ export const internalConfigRoutes = async (app) => { const config = await setAnonymityConfig(anonymity_enabled) return reply.send({ success: true, data: config }) }) + + app.get('/max-customers-per-mitra', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], + }, async (request, reply) => { + const config = await getMaxCustomersPerMitra() + return reply.send({ success: true, data: config }) + }) + + app.patch('/max-customers-per-mitra', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], + }, async (request, reply) => { + const { max_customers_per_mitra } = request.body ?? {} + if (typeof max_customers_per_mitra !== 'number' || max_customers_per_mitra < 1) { + return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'max_customers_per_mitra must be a positive number' } }) + } + const config = await setMaxCustomersPerMitra(max_customers_per_mitra) + return reply.send({ success: true, data: config }) + }) } diff --git a/backend/src/routes/internal/mitra.routes.js b/backend/src/routes/internal/mitra.routes.js index bde7663..de19454 100644 --- a/backend/src/routes/internal/mitra.routes.js +++ b/backend/src/routes/internal/mitra.routes.js @@ -1,6 +1,7 @@ import { authenticate, requirePermission } from '../../plugins/auth.js' import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js' import { createMitra, listMitras, updateMitraStatus } from '../../services/mitra.service.js' +import { getOnlineMitras, getOnlineLogs } from '../../services/mitra-status.service.js' const attachCcUser = async (request, reply) => { const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) @@ -42,4 +43,19 @@ export const mitraManagementRoutes = async (app) => { const mitra = await updateMitraStatus(request.params.id, is_active) return reply.send({ success: true, data: mitra }) }) + + app.get('/online', { + preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'read')], + }, async (request, reply) => { + const mitras = await getOnlineMitras() + return reply.send({ success: true, data: mitras }) + }) + + app.get('/:id/online-logs', { + preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'read')], + }, async (request, reply) => { + const { page = 1, limit = 50 } = request.query + const result = await getOnlineLogs(request.params.id, { page: Number(page), limit: Number(limit) }) + return reply.send({ success: true, data: result }) + }) } diff --git a/backend/src/routes/internal/session.routes.js b/backend/src/routes/internal/session.routes.js new file mode 100644 index 0000000..2d62b95 --- /dev/null +++ b/backend/src/routes/internal/session.routes.js @@ -0,0 +1,48 @@ +import { authenticate, requirePermission } from '../../plugins/auth.js' +import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js' +import { listSessions, getSessionById, rerouteSession } from '../../services/session.service.js' +import { getDashboardStats } from '../../services/dashboard.service.js' + +const attachCcUser = async (request, reply) => { + const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) + if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } }) + request.ccUser = user +} + +export const sessionManagementRoutes = async (app) => { + app.get('/dashboard/stats', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], + }, async (request, reply) => { + const stats = await getDashboardStats() + return reply.send({ success: true, data: stats }) + }) + + app.get('/', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], + }, async (request, reply) => { + const { page = 1, limit = 20, status } = request.query + const result = await listSessions({ page: Number(page), limit: Number(limit), status }) + return reply.send({ success: true, data: result }) + }) + + app.get('/:sessionId', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], + }, async (request, reply) => { + const session = await getSessionById(request.params.sessionId) + if (!session) { + return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Session not found' } }) + } + return reply.send({ success: true, data: session }) + }) + + app.post('/:sessionId/reroute', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], + }, async (request, reply) => { + const { new_mitra_id } = request.body ?? {} + if (!new_mitra_id) { + return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'new_mitra_id is required' } }) + } + const session = await rerouteSession(request.params.sessionId, new_mitra_id) + return reply.send({ success: true, data: session }) + }) +} diff --git a/backend/src/routes/public/client.chat.routes.js b/backend/src/routes/public/client.chat.routes.js new file mode 100644 index 0000000..7c00681 --- /dev/null +++ b/backend/src/routes/public/client.chat.routes.js @@ -0,0 +1,75 @@ +import { authenticate } from '../../plugins/auth.js' +import { getCustomerByFirebaseUid } from '../../services/customer.service.js' +import { createPairingRequest, cancelPairingRequest, getSessionStatus } from '../../services/pairing.service.js' +import { getActiveSessionByCustomer, endSession } from '../../services/session.service.js' +import { subscribe } from '../../plugins/valkey.js' + +const resolveCustomer = async (request, reply) => { + const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid) + if (!customer) { + return reply.code(404).send({ + success: false, + error: { code: 'ACCOUNT_NOT_FOUND', message: 'Customer account not found' }, + }) + } + request.customer = customer +} + +export const clientChatRoutes = async (app) => { + app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + const session = await createPairingRequest(request.customer.id) + return reply.code(201).send({ success: true, data: session }) + }) + + app.get('/request/:sessionId/status', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + const { sessionId } = request.params + + // SSE stream for real-time status updates + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }) + + // Send current status immediately + const current = await getSessionStatus(sessionId) + if (current) { + reply.raw.write(`data: ${JSON.stringify(current)}\n\n`) + } + + // If already in a terminal state, close + if (current && ['active', 'completed', 'cancelled', 'expired'].includes(current.status)) { + reply.raw.end() + return + } + + // Subscribe to status updates + const unsubscribe = subscribe(`session:${sessionId}:status`, (data) => { + reply.raw.write(`data: ${JSON.stringify(data)}\n\n`) + if (['paired', 'expired', 'session_ended'].includes(data.type)) { + reply.raw.end() + unsubscribe() + } + }) + + // Clean up on client disconnect + request.raw.on('close', () => { + unsubscribe() + }) + }) + + app.post('/request/:sessionId/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + const session = await cancelPairingRequest(request.params.sessionId, request.customer.id) + return reply.send({ success: true, data: session }) + }) + + app.get('/session/active', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + const session = await getActiveSessionByCustomer(request.customer.id) + return reply.send({ success: true, data: session ?? null }) + }) + + app.post('/session/:sessionId/end', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + const session = await endSession(request.params.sessionId, 'customer') + return reply.send({ success: true, data: session }) + }) +} diff --git a/backend/src/routes/public/mitra.chat.routes.js b/backend/src/routes/public/mitra.chat.routes.js new file mode 100644 index 0000000..974e450 --- /dev/null +++ b/backend/src/routes/public/mitra.chat.routes.js @@ -0,0 +1,69 @@ +import { authenticate } from '../../plugins/auth.js' +import { getMitraByFirebaseUid } from '../../services/mitra.service.js' +import { acceptPairingRequest, declinePairingRequest } from '../../services/pairing.service.js' +import { getActiveSessionsByMitra, endSession } from '../../services/session.service.js' +import { subscribe } from '../../plugins/valkey.js' + +const resolveMitra = async (request, reply) => { + const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid) + if (!mitra) { + return reply.code(404).send({ + success: false, + error: { code: 'ACCOUNT_NOT_FOUND', message: 'Mitra account not found' }, + }) + } + if (!mitra.is_active) { + return reply.code(403).send({ + success: false, + error: { code: 'ACCOUNT_INACTIVE', message: 'Account is inactive' }, + }) + } + request.mitra = mitra +} + +export const mitraChatRoutes = async (app) => { + app.get('/incoming', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { + const mitraId = request.mitra.id + + // SSE stream for incoming chat requests + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }) + + // Keep-alive ping + const pingInterval = setInterval(() => { + reply.raw.write(': ping\n\n') + }, 15_000) + + const unsubscribe = subscribe(`mitra:${mitraId}:requests`, (data) => { + reply.raw.write(`data: ${JSON.stringify(data)}\n\n`) + }) + + request.raw.on('close', () => { + clearInterval(pingInterval) + unsubscribe() + }) + }) + + app.post('/:sessionId/accept', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { + const session = await acceptPairingRequest(request.params.sessionId, request.mitra.id) + return reply.send({ success: true, data: session }) + }) + + app.post('/:sessionId/decline', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { + await declinePairingRequest(request.params.sessionId, request.mitra.id) + return reply.send({ success: true }) + }) + + app.get('/sessions/active', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { + const sessions = await getActiveSessionsByMitra(request.mitra.id) + return reply.send({ success: true, data: sessions }) + }) + + app.post('/sessions/:sessionId/end', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { + const session = await endSession(request.params.sessionId, 'mitra') + return reply.send({ success: true, data: session }) + }) +} diff --git a/backend/src/routes/public/mitra.status.routes.js b/backend/src/routes/public/mitra.status.routes.js new file mode 100644 index 0000000..5a8927d --- /dev/null +++ b/backend/src/routes/public/mitra.status.routes.js @@ -0,0 +1,43 @@ +import { authenticate } from '../../plugins/auth.js' +import { getMitraByFirebaseUid } from '../../services/mitra.service.js' +import * as mitraStatusService from '../../services/mitra-status.service.js' + +export const mitraStatusRoutes = async (app) => { + // Resolve mitra from Firebase token + const resolveMitra = async (request, reply) => { + const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid) + if (!mitra) { + return reply.code(404).send({ + success: false, + error: { code: 'ACCOUNT_NOT_FOUND', message: 'Mitra account not found' }, + }) + } + if (!mitra.is_active) { + return reply.code(403).send({ + success: false, + error: { code: 'ACCOUNT_INACTIVE', message: 'Account is inactive' }, + }) + } + request.mitra = mitra + } + + app.post('/online', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { + await mitraStatusService.setOnline(request.mitra.id) + return reply.send({ success: true, data: { is_online: true } }) + }) + + app.post('/offline', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { + await mitraStatusService.setOffline(request.mitra.id) + return reply.send({ success: true, data: { is_online: false } }) + }) + + app.post('/heartbeat', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { + await mitraStatusService.heartbeat(request.mitra.id) + return reply.send({ success: true }) + }) + + app.get('/', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { + const status = await mitraStatusService.getStatus(request.mitra.id) + return reply.send({ success: true, data: status }) + }) +} diff --git a/backend/src/server.js b/backend/src/server.js index f4edca7..4ba5880 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,6 +1,7 @@ import 'dotenv/config' import { buildPublicApp } from './app.public.js' import { buildInternalApp } from './app.internal.js' +import { autoOfflineStaleMitras } from './services/mitra-status.service.js' const PUBLIC_PORT = process.env.PUBLIC_PORT || 3000 const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001 @@ -15,6 +16,16 @@ const start = async () => { await internalApp.listen({ port: INTERNAL_PORT, host: INTERNAL_HOST }) console.log(`Internal API listening on ${INTERNAL_HOST}:${INTERNAL_PORT}`) + + // Auto-offline mitras with stale heartbeat (every 30s) + setInterval(async () => { + try { + const count = await autoOfflineStaleMitras(45) + if (count > 0) console.log(`Auto-offlined ${count} stale mitra(s)`) + } catch (err) { + console.error('Auto-offline check failed:', err) + } + }, 30_000) } start().catch((err) => { diff --git a/backend/src/services/config.service.js b/backend/src/services/config.service.js index a8be2e7..b3047e4 100644 --- a/backend/src/services/config.service.js +++ b/backend/src/services/config.service.js @@ -15,3 +15,17 @@ export const setAnonymityConfig = async (enabled) => { ` return { anonymity_enabled: enabled } } + +export const getMaxCustomersPerMitra = async () => { + const [row] = await sql`SELECT value FROM app_config WHERE key = 'max_customers_per_mitra'` + return { max_customers_per_mitra: row?.value?.value ?? 3 } +} + +export const setMaxCustomersPerMitra = async (value) => { + await sql` + INSERT INTO app_config (key, value, updated_at) + VALUES ('max_customers_per_mitra', ${sql.json({ value })}, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + ` + return { max_customers_per_mitra: value } +} diff --git a/backend/src/services/dashboard.service.js b/backend/src/services/dashboard.service.js new file mode 100644 index 0000000..01a348e --- /dev/null +++ b/backend/src/services/dashboard.service.js @@ -0,0 +1,28 @@ +import { getDb } from '../db/client.js' + +const sql = getDb() + +export const getDashboardStats = async () => { + const [[{ active_chats }], [{ online_mitras }], [{ pending_requests }]] = await Promise.all([ + sql`SELECT COUNT(*) AS active_chats FROM chat_sessions WHERE status IN ('active', 'pending_payment')`, + sql`SELECT COUNT(*) AS online_mitras FROM mitra_online_status WHERE is_online = true`, + sql`SELECT COUNT(*) AS pending_requests FROM chat_sessions WHERE status IN ('searching', 'pending_acceptance')`, + ]) + + const customersPerMitra = await sql` + SELECT m.id, m.display_name, + (SELECT COUNT(*) FROM chat_sessions cs + WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment')) AS active_session_count + FROM mitras m + INNER JOIN mitra_online_status s ON s.mitra_id = m.id + WHERE s.is_online = true + ORDER BY active_session_count DESC + ` + + return { + active_chats: Number(active_chats), + online_mitras: Number(online_mitras), + pending_requests: Number(pending_requests), + customers_per_mitra: customersPerMitra, + } +} diff --git a/backend/src/services/mitra-status.service.js b/backend/src/services/mitra-status.service.js new file mode 100644 index 0000000..7bfae4a --- /dev/null +++ b/backend/src/services/mitra-status.service.js @@ -0,0 +1,107 @@ +import { getDb } from '../db/client.js' + +const sql = getDb() + +export const ensureStatusRow = async (mitraId) => { + await sql` + INSERT INTO mitra_online_status (mitra_id) + VALUES (${mitraId}) + ON CONFLICT (mitra_id) DO NOTHING + ` +} + +export const setOnline = async (mitraId) => { + await ensureStatusRow(mitraId) + const now = new Date() + await sql` + UPDATE mitra_online_status + SET is_online = true, last_online_at = ${now}, last_heartbeat_at = ${now}, updated_at = ${now} + WHERE mitra_id = ${mitraId} + ` + await sql` + INSERT INTO mitra_online_logs (mitra_id, status) VALUES (${mitraId}, 'online') + ` +} + +export const setOffline = async (mitraId) => { + await ensureStatusRow(mitraId) + const now = new Date() + const [status] = await sql` + SELECT is_online FROM mitra_online_status WHERE mitra_id = ${mitraId} + ` + if (!status?.is_online) return + + await sql` + UPDATE mitra_online_status + SET is_online = false, last_offline_at = ${now}, updated_at = ${now} + WHERE mitra_id = ${mitraId} + ` + await sql` + INSERT INTO mitra_online_logs (mitra_id, status) VALUES (${mitraId}, 'offline') + ` +} + +export const heartbeat = async (mitraId) => { + const now = new Date() + await sql` + UPDATE mitra_online_status + SET last_heartbeat_at = ${now}, updated_at = ${now} + WHERE mitra_id = ${mitraId} AND is_online = true + ` +} + +export const getStatus = async (mitraId) => { + await ensureStatusRow(mitraId) + const [status] = await sql` + SELECT is_online, last_online_at, last_offline_at, updated_at + FROM mitra_online_status + WHERE mitra_id = ${mitraId} + ` + return status +} + +export const getOnlineMitras = async () => { + const mitras = await sql` + SELECT m.id, m.display_name, m.phone, s.last_online_at, s.updated_at, + (SELECT COUNT(*) FROM chat_sessions cs + WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment')) AS active_session_count + FROM mitras m + INNER JOIN mitra_online_status s ON s.mitra_id = m.id + WHERE s.is_online = true AND m.is_active = true + ORDER BY s.last_online_at DESC + ` + return mitras +} + +export const getOnlineLogs = async (mitraId, { page = 1, limit = 50 } = {}) => { + const offset = (page - 1) * limit + const items = await sql` + SELECT id, status, timestamp + FROM mitra_online_logs + WHERE mitra_id = ${mitraId} + ORDER BY timestamp DESC + LIMIT ${limit} OFFSET ${offset} + ` + const [{ count }] = await sql` + SELECT COUNT(*) FROM mitra_online_logs WHERE mitra_id = ${mitraId} + ` + return { items, total: Number(count), page, limit } +} + +export const autoOfflineStaleMitras = async (staleSeconds = 45) => { + const stale = await sql` + UPDATE mitra_online_status + SET is_online = false, last_offline_at = NOW(), updated_at = NOW() + WHERE is_online = true + AND last_heartbeat_at < NOW() - ${staleSeconds + ' seconds'}::interval + RETURNING mitra_id + ` + + for (const row of stale) { + await sql` + INSERT INTO mitra_online_logs (mitra_id, status) VALUES (${row.mitra_id}, 'offline') + ` + } + + return stale.length +} diff --git a/backend/src/services/pairing.service.js b/backend/src/services/pairing.service.js new file mode 100644 index 0000000..31cf8c4 --- /dev/null +++ b/backend/src/services/pairing.service.js @@ -0,0 +1,247 @@ +import { getDb } from '../db/client.js' +import { getMaxCustomersPerMitra } from './config.service.js' +import { publish } from '../plugins/valkey.js' + +const sql = getDb() + +// Timeout map for active pairing requests (sessionId → timeoutId) +const pairingTimeouts = new Map() + +export const findAvailableMitras = async () => { + const { max_customers_per_mitra } = await getMaxCustomersPerMitra() + const mitras = await sql` + SELECT m.id, m.display_name + FROM mitras m + INNER JOIN mitra_online_status s ON s.mitra_id = m.id + WHERE m.is_active = true + AND s.is_online = true + AND ( + SELECT COUNT(*) FROM chat_sessions cs + WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment') + ) < ${max_customers_per_mitra} + ` + return mitras +} + +export const createPairingRequest = async (customerId) => { + // Check for existing active session or request + const [existing] = await sql` + SELECT id, status FROM chat_sessions + WHERE customer_id = ${customerId} + AND status IN ('searching', 'pending_acceptance', 'pending_payment', 'active') + ` + if (existing) { + throw Object.assign(new Error('Customer already has an active session or request'), { + code: 'ALREADY_ACTIVE', statusCode: 409, + }) + } + + const availableMitras = await findAvailableMitras() + if (availableMitras.length === 0) { + throw Object.assign(new Error('No bestie available'), { + code: 'NO_MITRA_AVAILABLE', statusCode: 404, + }) + } + + // Create session + const [session] = await sql` + INSERT INTO chat_sessions (customer_id, status) + VALUES (${customerId}, 'pending_acceptance') + RETURNING id, customer_id, status, created_at + ` + + // Create notifications for all available mitras + for (const mitra of availableMitras) { + await sql` + INSERT INTO chat_request_notifications (session_id, mitra_id) + VALUES (${session.id}, ${mitra.id}) + ` + // Publish to mitra's channel + await publish(`mitra:${mitra.id}:requests`, { + type: 'chat_request', + session_id: session.id, + created_at: session.created_at, + }) + } + + // Start 60s timeout + const timeoutId = setTimeout(async () => { + try { + await expirePairingRequest(session.id) + } catch (_) {} + }, 60_000) + pairingTimeouts.set(session.id, timeoutId) + + return session +} + +export const acceptPairingRequest = async (sessionId, mitraId) => { + // Use a transaction-like approach: update only if status is still pending_acceptance + const [session] = await sql` + UPDATE chat_sessions + SET mitra_id = ${mitraId}, status = 'pending_payment', paired_at = NOW() + WHERE id = ${sessionId} AND status = 'pending_acceptance' AND mitra_id IS NULL + RETURNING id, customer_id, mitra_id, status, paired_at + ` + + if (!session) { + throw Object.assign(new Error('Request already accepted or expired'), { + code: 'REQUEST_UNAVAILABLE', statusCode: 409, + }) + } + + // Mark this mitra's notification as accepted + await sql` + UPDATE chat_request_notifications + SET response = 'accepted', responded_at = NOW() + WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} + ` + + // Mark other mitras' notifications as ignored + await sql` + UPDATE chat_request_notifications + SET response = 'ignored', responded_at = NOW() + WHERE session_id = ${sessionId} AND mitra_id != ${mitraId} AND response IS NULL + ` + + // Clear timeout + const timeoutId = pairingTimeouts.get(sessionId) + if (timeoutId) { + clearTimeout(timeoutId) + pairingTimeouts.delete(sessionId) + } + + // Auto-skip payment for now: move to active + const [activeSession] = await sql` + UPDATE chat_sessions SET status = 'active' + WHERE id = ${sessionId} + RETURNING id, customer_id, mitra_id, status, paired_at + ` + + // Get mitra display name for customer notification + const [mitra] = await sql` + SELECT display_name FROM mitras WHERE id = ${mitraId} + ` + + // Notify customer + await publish(`session:${sessionId}:status`, { + type: 'paired', + session_id: sessionId, + mitra_display_name: mitra.display_name, + status: 'active', + }) + + // Notify other mitras to dismiss the request + const notifications = await sql` + SELECT mitra_id FROM chat_request_notifications + WHERE session_id = ${sessionId} AND mitra_id != ${mitraId} + ` + for (const n of notifications) { + await publish(`mitra:${n.mitra_id}:requests`, { + type: 'chat_request_closed', + session_id: sessionId, + }) + } + + return activeSession +} + +export const declinePairingRequest = async (sessionId, mitraId) => { + await sql` + UPDATE chat_request_notifications + SET response = 'declined', responded_at = NOW() + WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} AND response IS NULL + ` +} + +export const cancelPairingRequest = async (sessionId, customerId) => { + const [session] = await sql` + UPDATE chat_sessions + SET status = 'cancelled' + WHERE id = ${sessionId} AND customer_id = ${customerId} + AND status IN ('searching', 'pending_acceptance') + RETURNING id, status + ` + + if (!session) { + throw Object.assign(new Error('Cannot cancel this request'), { + code: 'CANNOT_CANCEL', statusCode: 409, + }) + } + + // Clear timeout + const timeoutId = pairingTimeouts.get(sessionId) + if (timeoutId) { + clearTimeout(timeoutId) + pairingTimeouts.delete(sessionId) + } + + // Mark all notifications as ignored + await sql` + UPDATE chat_request_notifications + SET response = 'ignored', responded_at = NOW() + WHERE session_id = ${sessionId} AND response IS NULL + ` + + // Notify mitras to dismiss + const notifications = await sql` + SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} + ` + for (const n of notifications) { + await publish(`mitra:${n.mitra_id}:requests`, { + type: 'chat_request_closed', + session_id: sessionId, + }) + } + + return session +} + +export const expirePairingRequest = async (sessionId) => { + const [session] = await sql` + UPDATE chat_sessions + SET status = 'expired' + WHERE id = ${sessionId} AND status = 'pending_acceptance' + RETURNING id, customer_id, status + ` + if (!session) return null + + pairingTimeouts.delete(sessionId) + + // Mark all pending notifications as ignored + await sql` + UPDATE chat_request_notifications + SET response = 'ignored', responded_at = NOW() + WHERE session_id = ${sessionId} AND response IS NULL + ` + + // Notify customer + await publish(`session:${sessionId}:status`, { + type: 'expired', + session_id: sessionId, + }) + + // Notify mitras to dismiss + const notifications = await sql` + SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} + ` + for (const n of notifications) { + await publish(`mitra:${n.mitra_id}:requests`, { + type: 'chat_request_closed', + session_id: sessionId, + }) + } + + return session +} + +export const getSessionStatus = async (sessionId) => { + const [session] = await sql` + SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by, + m.display_name AS mitra_display_name + FROM chat_sessions cs + LEFT JOIN mitras m ON m.id = cs.mitra_id + WHERE cs.id = ${sessionId} + ` + return session +} diff --git a/backend/src/services/session.service.js b/backend/src/services/session.service.js new file mode 100644 index 0000000..e21600b --- /dev/null +++ b/backend/src/services/session.service.js @@ -0,0 +1,149 @@ +import { getDb } from '../db/client.js' +import { publish } from '../plugins/valkey.js' + +const sql = getDb() + +export const getActiveSessionByCustomer = async (customerId) => { + const [session] = await sql` + SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, + m.display_name AS mitra_display_name + FROM chat_sessions cs + LEFT JOIN mitras m ON m.id = cs.mitra_id + WHERE cs.customer_id = ${customerId} + AND cs.status IN ('active', 'pending_payment') + ORDER BY cs.created_at DESC LIMIT 1 + ` + return session +} + +export const getActiveSessionsByMitra = async (mitraId) => { + const sessions = await sql` + SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at, + c.display_name AS customer_display_name + FROM chat_sessions cs + INNER JOIN customers c ON c.id = cs.customer_id + WHERE cs.mitra_id = ${mitraId} + AND cs.status IN ('active', 'pending_payment') + ORDER BY cs.created_at DESC + ` + return sessions +} + +export const endSession = async (sessionId, endedBy) => { + const [session] = await sql` + UPDATE chat_sessions + SET status = 'completed', ended_at = NOW(), ended_by = ${endedBy} + WHERE id = ${sessionId} AND status IN ('active', 'pending_payment') + RETURNING id, customer_id, mitra_id, status, ended_at, ended_by + ` + + if (!session) { + throw Object.assign(new Error('Session not found or already ended'), { + code: 'SESSION_NOT_ACTIVE', statusCode: 409, + }) + } + + // Notify both parties + await publish(`session:${sessionId}:status`, { + type: 'session_ended', + session_id: sessionId, + ended_by: endedBy, + }) + + return session +} + +export const rerouteSession = async (sessionId, newMitraId) => { + // Get current session + const [current] = await sql` + SELECT id, customer_id, mitra_id, status FROM chat_sessions + WHERE id = ${sessionId} AND status IN ('active', 'pending_payment') + ` + + if (!current) { + throw Object.assign(new Error('Session not found or not active'), { + code: 'SESSION_NOT_ACTIVE', statusCode: 409, + }) + } + + // Verify new mitra is online + const [newMitraStatus] = await sql` + SELECT is_online FROM mitra_online_status WHERE mitra_id = ${newMitraId} + ` + if (!newMitraStatus?.is_online) { + throw Object.assign(new Error('Target mitra is not online'), { + code: 'MITRA_NOT_ONLINE', statusCode: 422, + }) + } + + const oldMitraId = current.mitra_id + + // Update session with new mitra (forced assignment) + const [session] = await sql` + UPDATE chat_sessions + SET mitra_id = ${newMitraId} + WHERE id = ${sessionId} + RETURNING id, customer_id, mitra_id, status + ` + + const [newMitra] = await sql` + SELECT display_name FROM mitras WHERE id = ${newMitraId} + ` + + // Notify customer about reroute + await publish(`session:${sessionId}:status`, { + type: 'rerouted', + session_id: sessionId, + mitra_display_name: newMitra.display_name, + }) + + // Notify old mitra session removed + if (oldMitraId) { + await publish(`mitra:${oldMitraId}:requests`, { + type: 'session_rerouted', + session_id: sessionId, + }) + } + + // Notify new mitra about new session + await publish(`mitra:${newMitraId}:requests`, { + type: 'session_assigned', + session_id: sessionId, + }) + + return session +} + +export const listSessions = async ({ page = 1, limit = 20, status } = {}) => { + const offset = (page - 1) * limit + const conditions = status + ? sql`WHERE cs.status = ${status}` + : sql`` + + const items = await sql` + SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by, + c.display_name AS customer_display_name, + m.display_name AS mitra_display_name + FROM chat_sessions cs + INNER JOIN customers c ON c.id = cs.customer_id + LEFT JOIN mitras m ON m.id = cs.mitra_id + ${conditions} + ORDER BY cs.created_at DESC + LIMIT ${limit} OFFSET ${offset} + ` + const [{ count }] = await sql`SELECT COUNT(*) FROM chat_sessions cs ${conditions}` + return { items, total: Number(count), page, limit } +} + +export const getSessionById = async (sessionId) => { + const [session] = await sql` + SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by, + c.display_name AS customer_display_name, + m.display_name AS mitra_display_name + FROM chat_sessions cs + INNER JOIN customers c ON c.id = cs.customer_id + LEFT JOIN mitras m ON m.id = cs.mitra_id + WHERE cs.id = ${sessionId} + ` + return session +} diff --git a/client_app/lib/core/api/api_client.dart b/client_app/lib/core/api/api_client.dart index 13dd5e8..8b03f4f 100644 --- a/client_app/lib/core/api/api_client.dart +++ b/client_app/lib/core/api/api_client.dart @@ -2,7 +2,7 @@ import 'package:dio/dio.dart'; import 'package:firebase_auth/firebase_auth.dart'; class ApiClient { - static const String _baseUrl = String.fromEnvironment( + static const String baseUrl = String.fromEnvironment( 'API_BASE_URL', defaultValue: 'https://api.halobestie.com', ); @@ -10,7 +10,7 @@ class ApiClient { late final Dio _dio; ApiClient() { - _dio = Dio(BaseOptions(baseUrl: _baseUrl)); + _dio = Dio(BaseOptions(baseUrl: baseUrl)); _dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) async { final user = FirebaseAuth.instance.currentUser; diff --git a/client_app/lib/core/pairing/pairing_bloc.dart b/client_app/lib/core/pairing/pairing_bloc.dart new file mode 100644 index 0000000..ff9279e --- /dev/null +++ b/client_app/lib/core/pairing/pairing_bloc.dart @@ -0,0 +1,173 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:dio/dio.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../api/api_client.dart'; + +// Events +abstract class PairingEvent extends Equatable { + @override + List get props => []; +} + +class RequestPairing extends PairingEvent {} +class CancelPairing extends PairingEvent {} + +class _PairingStatusUpdate extends PairingEvent { + final Map data; + _PairingStatusUpdate(this.data); + @override + List get props => [data]; +} + +class _PairingTimeout extends PairingEvent {} + +// States +abstract class PairingState extends Equatable { + @override + List get props => []; +} + +class PairingInitial extends PairingState {} +class PairingSearching extends PairingState { + final String sessionId; + PairingSearching(this.sessionId); + @override + List get props => [sessionId]; +} + +class PairingBestieFound extends PairingState { + final String sessionId; + final String mitraName; + PairingBestieFound({required this.sessionId, required this.mitraName}); + @override + List get props => [sessionId, mitraName]; +} + +class PairingActive extends PairingState { + final String sessionId; + final String mitraName; + PairingActive({required this.sessionId, required this.mitraName}); + @override + List get props => [sessionId, mitraName]; +} + +class PairingNoBestie extends PairingState {} +class PairingCancelled extends PairingState {} + +class PairingError extends PairingState { + final String message; + PairingError(this.message); + @override + List get props => [message]; +} + +// Bloc +class PairingBloc extends Bloc { + final ApiClient apiClient; + Timer? _timeoutTimer; + StreamSubscription? _sseSubscription; + + PairingBloc({required this.apiClient}) : super(PairingInitial()) { + on(_onRequestPairing); + on(_onCancelPairing); + on<_PairingStatusUpdate>(_onStatusUpdate); + on<_PairingTimeout>(_onTimeout); + } + + Future _onRequestPairing(RequestPairing event, Emitter emit) async { + try { + final response = await apiClient.post('/api/client/chat/request'); + final data = response['data'] as Map; + final sessionId = data['id'] as String; + + emit(PairingSearching(sessionId)); + + // Start 60s local timeout as a safety net + _timeoutTimer = Timer(const Duration(seconds: 60), () { + add(_PairingTimeout()); + }); + + // Listen to SSE for status updates + _listenToSSE(sessionId); + } on DioException catch (e) { + final code = e.response?.data?['error']?['code']; + if (code == 'NO_MITRA_AVAILABLE') { + emit(PairingNoBestie()); + } else if (code == 'ALREADY_ACTIVE') { + emit(PairingError('Kamu sudah memiliki sesi aktif.')); + } else { + emit(PairingError('Gagal memulai. Coba lagi.')); + } + } + } + + void _listenToSSE(String sessionId) { + final dio = Dio(BaseOptions(baseUrl: ApiClient.baseUrl)); + // SSE endpoint — use responseType stream + dio.get( + '/api/client/chat/request/$sessionId/status', + options: Options(responseType: ResponseType.stream), + ).then((response) { + final stream = response.data.stream as Stream>; + _sseSubscription = stream + .transform(utf8.decoder) + .transform(const LineSplitter()) + .where((line) => line.startsWith('data: ')) + .map((line) => jsonDecode(line.substring(6)) as Map) + .listen( + (data) => add(_PairingStatusUpdate(data)), + onError: (_) {}, + ); + }).catchError((_) {}); + } + + Future _onStatusUpdate(_PairingStatusUpdate event, Emitter emit) async { + final data = event.data; + final type = data['type'] as String?; + + if (type == 'paired') { + _cleanup(); + final mitraName = data['mitra_display_name'] as String? ?? 'Bestie'; + final sessionId = data['session_id'] as String; + emit(PairingBestieFound(sessionId: sessionId, mitraName: mitraName)); + + // Brief delay then transition to active + await Future.delayed(const Duration(seconds: 2)); + emit(PairingActive(sessionId: sessionId, mitraName: mitraName)); + } else if (type == 'expired') { + _cleanup(); + emit(PairingNoBestie()); + } + } + + Future _onCancelPairing(CancelPairing event, Emitter emit) async { + if (state is PairingSearching) { + final sessionId = (state as PairingSearching).sessionId; + try { + await apiClient.post('/api/client/chat/request/$sessionId/cancel'); + } catch (_) {} + _cleanup(); + emit(PairingCancelled()); + } + } + + Future _onTimeout(_PairingTimeout event, Emitter emit) async { + _cleanup(); + emit(PairingNoBestie()); + } + + void _cleanup() { + _timeoutTimer?.cancel(); + _timeoutTimer = null; + _sseSubscription?.cancel(); + _sseSubscription = null; + } + + @override + Future close() { + _cleanup(); + return super.close(); + } +} diff --git a/client_app/lib/features/chat/screens/bestie_found_screen.dart b/client_app/lib/features/chat/screens/bestie_found_screen.dart new file mode 100644 index 0000000..da8571a --- /dev/null +++ b/client_app/lib/features/chat/screens/bestie_found_screen.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/pairing/pairing_bloc.dart'; + +class BestieFoundScreen extends StatelessWidget { + final String sessionId; + final String mitraName; + + const BestieFoundScreen({ + super.key, + required this.sessionId, + required this.mitraName, + }); + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state is PairingActive) { + context.go('/chat/session/${state.sessionId}', extra: state.mitraName); + } + }, + child: Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.check_circle, size: 80, color: Colors.green), + const SizedBox(height: 24), + const Text( + 'Bestie ditemukan!', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + 'Menghubungkan kamu ke $mitraName', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16, color: Colors.grey), + ), + const SizedBox(height: 24), + const CircularProgressIndicator(), + ], + ), + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/chat/screens/no_bestie_screen.dart b/client_app/lib/features/chat/screens/no_bestie_screen.dart new file mode 100644 index 0000000..b85dfa2 --- /dev/null +++ b/client_app/lib/features/chat/screens/no_bestie_screen.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class NoBestieScreen extends StatelessWidget { + const NoBestieScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.sentiment_dissatisfied, size: 80, color: Colors.orange), + const SizedBox(height: 24), + const Text( + 'Bestie belum tersedia', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + 'Maaf, semua Bestie sedang sibuk. Coba lagi nanti ya.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, color: Colors.grey), + ), + const SizedBox(height: 48), + ElevatedButton( + onPressed: () => context.go('/home'), + child: const Text('Kembali'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/chat/screens/searching_screen.dart b/client_app/lib/features/chat/screens/searching_screen.dart new file mode 100644 index 0000000..05a20a5 --- /dev/null +++ b/client_app/lib/features/chat/screens/searching_screen.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/pairing/pairing_bloc.dart'; + +class SearchingScreen extends StatelessWidget { + const SearchingScreen({super.key}); + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state is PairingBestieFound) { + context.go('/chat/found', extra: { + 'sessionId': state.sessionId, + 'mitraName': state.mitraName, + }); + } else if (state is PairingNoBestie) { + context.go('/chat/no-bestie'); + } else if (state is PairingCancelled) { + context.go('/home'); + } + }, + child: Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 32), + const Text( + 'Mencari Bestie...', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + 'Tunggu sebentar ya, kami sedang mencarikan Bestie untukmu', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, color: Colors.grey), + ), + const SizedBox(height: 48), + OutlinedButton( + onPressed: () => context.read().add(CancelPairing()), + child: const Text('Batalkan'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/chat/screens/session_active_screen.dart b/client_app/lib/features/chat/screens/session_active_screen.dart new file mode 100644 index 0000000..fd2fefe --- /dev/null +++ b/client_app/lib/features/chat/screens/session_active_screen.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/api/api_client.dart'; + +class SessionActiveScreen extends StatelessWidget { + final String sessionId; + final String mitraName; + + const SessionActiveScreen({ + super.key, + required this.sessionId, + required this.mitraName, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Sesi Aktif'), + automaticallyImplyLeading: false, + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.chat_bubble, size: 80, color: Colors.blue), + const SizedBox(height: 24), + Text( + 'Terhubung dengan $mitraName', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + 'Sesi chat akan tersedia di fase berikutnya.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + const SizedBox(height: 48), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + onPressed: () => _endSession(context), + child: const Text('Akhiri Sesi', style: TextStyle(color: Colors.white)), + ), + ], + ), + ), + ), + ); + } + + Future _endSession(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Akhiri Sesi?'), + content: const Text('Apakah kamu yakin ingin mengakhiri sesi ini?'), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Batal')), + TextButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Ya, Akhiri')), + ], + ), + ); + + if (confirmed == true && context.mounted) { + try { + final apiClient = context.read(); + await apiClient.post('/api/client/chat/session/$sessionId/end'); + if (context.mounted) context.go('/home'); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Gagal mengakhiri sesi. Coba lagi.')), + ); + } + } + } + } +} diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart index cfb053d..74e731d 100644 --- a/client_app/lib/features/home/home_screen.dart +++ b/client_app/lib/features/home/home_screen.dart @@ -1,39 +1,66 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import '../../core/auth/auth_bloc.dart'; +import '../../core/pairing/pairing_bloc.dart'; -/// Phase 1 placeholder — will be replaced in Phase 2 with chat/session features. class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final displayName = state is AuthAuthenticated - ? state.profile['display_name'] as String - : state is AuthAnonymous - ? state.displayName - : ''; - - return Scaffold( - appBar: AppBar( - title: const Text('Halo Bestie'), - actions: [ - IconButton( - icon: const Icon(Icons.logout), - onPressed: () => context.read().add(LogoutRequested()), - ), - ], - ), - body: Center( - child: Text( - 'Halo, $displayName!', - style: const TextStyle(fontSize: 24), - ), - ), - ); + return BlocListener( + listener: (context, state) { + if (state is PairingSearching) { + context.go('/chat/searching'); + } else if (state is PairingNoBestie) { + context.go('/chat/no-bestie'); + } else if (state is PairingError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.message)), + ); + } }, + child: BlocBuilder( + builder: (context, state) { + final displayName = state is AuthAuthenticated + ? state.profile['display_name'] as String + : state is AuthAnonymous + ? state.displayName + : ''; + + return Scaffold( + appBar: AppBar( + title: const Text('Halo Bestie'), + actions: [ + IconButton( + icon: const Icon(Icons.logout), + onPressed: () => context.read().add(LogoutRequested()), + ), + ], + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)), + const SizedBox(height: 48), + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16), + ), + onPressed: () => context.read().add(RequestPairing()), + child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)), + ), + ], + ), + ), + ), + ); + }, + ), ); } } diff --git a/client_app/lib/main.dart b/client_app/lib/main.dart index 25e2b5e..11cb28c 100644 --- a/client_app/lib/main.dart +++ b/client_app/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'core/api/api_client.dart'; import 'core/auth/auth_bloc.dart'; +import 'core/pairing/pairing_bloc.dart'; import 'firebase_options.dart'; import 'router.dart'; @@ -17,8 +18,13 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => AuthBloc(apiClient: ApiClient())..add(AppStarted()), + final apiClient = ApiClient(); + return MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => AuthBloc(apiClient: apiClient)..add(AppStarted())), + BlocProvider(create: (_) => PairingBloc(apiClient: apiClient)), + RepositoryProvider.value(value: apiClient), + ], child: BlocBuilder( builder: (context, state) { return MaterialApp.router( diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index 46265c0..7c53c65 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -7,6 +7,10 @@ import 'features/auth/screens/register_screen.dart'; import 'features/auth/screens/otp_screen.dart'; import 'features/auth/screens/force_register_screen.dart'; import 'features/home/home_screen.dart'; +import 'features/chat/screens/searching_screen.dart'; +import 'features/chat/screens/bestie_found_screen.dart'; +import 'features/chat/screens/no_bestie_screen.dart'; +import 'features/chat/screens/session_active_screen.dart'; GoRouter buildRouter(AuthBloc authBloc) { return GoRouter( @@ -30,6 +34,21 @@ GoRouter buildRouter(AuthBloc authBloc) { GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)), GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()), GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), + GoRoute(path: '/chat/searching', builder: (_, __) => const SearchingScreen()), + GoRoute(path: '/chat/found', builder: (context, state) { + final extra = state.extra as Map; + return BestieFoundScreen( + sessionId: extra['sessionId'] as String, + mitraName: extra['mitraName'] as String, + ); + }), + GoRoute(path: '/chat/no-bestie', builder: (_, __) => const NoBestieScreen()), + GoRoute(path: '/chat/session/:sessionId', builder: (context, state) { + return SessionActiveScreen( + sessionId: state.pathParameters['sessionId']!, + mitraName: state.extra as String? ?? 'Bestie', + ); + }), ], ); } diff --git a/control_center/src/App.jsx b/control_center/src/App.jsx index 545944e..73c45f3 100644 --- a/control_center/src/App.jsx +++ b/control_center/src/App.jsx @@ -1,7 +1,9 @@ import { Routes, Route, Navigate } from 'react-router-dom' import { useAuth } from './core/auth/AuthContext' import LoginPage from './pages/login/LoginPage' +import DashboardPage from './pages/dashboard/DashboardPage' import MitrasPage from './pages/mitras/MitrasPage' +import SessionsPage from './pages/sessions/SessionsPage' import UsersPage from './pages/users/UsersPage' import SettingsPage from './pages/settings/SettingsPage' import Layout from './components/Layout' @@ -17,8 +19,10 @@ export default function App() { } /> }> - } /> + } /> + } /> } /> + } /> } /> } /> diff --git a/control_center/src/components/Layout.jsx b/control_center/src/components/Layout.jsx index b17f5fc..48b2591 100644 --- a/control_center/src/components/Layout.jsx +++ b/control_center/src/components/Layout.jsx @@ -9,7 +9,9 @@ export default function Layout() {