From b4efcf14c231293f4d21c727651eeebe45a0de73 Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Tue, 7 Apr 2026 23:58:11 +0800 Subject: [PATCH] Phase 3 scaffold: chat engine (WebSocket, FCM, pricing, timer, extension, history) - Backend: WebSocket plugin, chat/pricing/timer/extension/closure/notification services - Client app: ChatBloc, pricing dialog, chat screen with message status, extension/goodbye flow, history - Mitra app: MitraChatBloc, ExtensionBloc, chat screen, extension accept/reject, history - Control center: free trial, extension timeout, early end config toggles - DB migration: chat_messages, session_closures, session_extensions, customer_transactions tables Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 8 +- backend/package-lock.json | 73 ++- backend/package.json | 1 + backend/src/app.public.js | 7 + backend/src/db/migrate.js | 119 +++++ backend/src/plugins/websocket.js | 151 ++++++ backend/src/routes/internal/config.routes.js | 59 ++- .../src/routes/public/client.chat.routes.js | 69 ++- .../src/routes/public/mitra.chat.routes.js | 26 +- .../src/routes/public/shared.chat.routes.js | 79 +++ backend/src/server.js | 4 + backend/src/services/chat-handler.service.js | 82 +++ backend/src/services/chat.service.js | 127 +++++ backend/src/services/closure.service.js | 106 ++++ backend/src/services/config.service.js | 70 +++ backend/src/services/extension.service.js | 159 ++++++ backend/src/services/notification.service.js | 52 ++ backend/src/services/pairing.service.js | 40 +- backend/src/services/pricing.service.js | 54 ++ backend/src/services/session-timer.service.js | 100 ++++ backend/src/services/session.service.js | 49 +- client_app/lib/core/chat/chat_bloc.dart | 385 ++++++++++++++ .../lib/core/chat/chat_opening_bloc.dart | 87 ++++ .../lib/core/chat/session_closure_bloc.dart | 90 ++++ client_app/lib/core/pairing/pairing_bloc.dart | 33 +- .../chat/screens/chat_history_screen.dart | 71 +++ .../features/chat/screens/chat_screen.dart | 352 +++++++++++++ .../chat/screens/chat_transcript_screen.dart | 91 ++++ .../chat/widgets/pricing_bottom_sheet.dart | 119 +++++ client_app/lib/features/home/home_screen.dart | 7 +- client_app/lib/main.dart | 25 + client_app/lib/router.dart | 13 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 + client_app/pubspec.lock | 40 ++ client_app/pubspec.yaml | 4 +- .../src/pages/settings/SettingsPage.jsx | 137 ++++- mitra_app/lib/core/chat/extension_bloc.dart | 84 ++++ mitra_app/lib/core/chat/mitra_chat_bloc.dart | 351 +++++++++++++ .../chat/screens/chat_history_screen.dart | 71 +++ .../chat/screens/chat_transcript_screen.dart | 91 ++++ .../chat/screens/mitra_chat_screen.dart | 337 +++++++++++++ mitra_app/lib/features/home/home_screen.dart | 26 +- mitra_app/lib/main.dart | 21 + mitra_app/lib/router.dart | 14 + mitra_app/pubspec.lock | 40 ++ mitra_app/pubspec.yaml | 4 +- requirement/phase3-plan.md | 475 ++++++++++++++++++ 47 files changed, 4361 insertions(+), 44 deletions(-) create mode 100644 backend/src/plugins/websocket.js create mode 100644 backend/src/routes/public/shared.chat.routes.js create mode 100644 backend/src/services/chat-handler.service.js create mode 100644 backend/src/services/chat.service.js create mode 100644 backend/src/services/closure.service.js create mode 100644 backend/src/services/extension.service.js create mode 100644 backend/src/services/notification.service.js create mode 100644 backend/src/services/pricing.service.js create mode 100644 backend/src/services/session-timer.service.js create mode 100644 client_app/lib/core/chat/chat_bloc.dart create mode 100644 client_app/lib/core/chat/chat_opening_bloc.dart create mode 100644 client_app/lib/core/chat/session_closure_bloc.dart create mode 100644 client_app/lib/features/chat/screens/chat_history_screen.dart create mode 100644 client_app/lib/features/chat/screens/chat_screen.dart create mode 100644 client_app/lib/features/chat/screens/chat_transcript_screen.dart create mode 100644 client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart create mode 100644 mitra_app/lib/core/chat/extension_bloc.dart create mode 100644 mitra_app/lib/core/chat/mitra_chat_bloc.dart create mode 100644 mitra_app/lib/features/chat/screens/chat_history_screen.dart create mode 100644 mitra_app/lib/features/chat/screens/chat_transcript_screen.dart create mode 100644 mitra_app/lib/features/chat/screens/mitra_chat_screen.dart create mode 100644 requirement/phase3-plan.md diff --git a/CLAUDE.md b/CLAUDE.md index e5e3f61..73d53d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ 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** use Valkey pub/sub for in-app events; FCM push notifications planned for next phase +- **Real-time features** use WebSocket for chat + Valkey pub/sub as backend bus; FCM push for background notifications - **Pairing** uses blast-to-all-available-mitras with first-come-first-served acceptance ## Current Progress @@ -47,6 +47,12 @@ Mental health chat platform connecting clients (users seeking support) with trai - 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` +- **Phase 3 (Chat Engine)** — fully scaffolded + - Backend: WebSocket plugin, chat message service, session timer (backend-authoritative), extension/closure services, FCM push notifications, pricing service (mock tiers + free trial) + - client_app: ChatBloc (WebSocket), ChatOpeningBloc (pricing), SessionClosureBloc, chat screen with message status (sent/delivered/read), typing indicator, extension/goodbye flow, chat history + transcript screens, FCM setup + - mitra_app: MitraChatBloc (WebSocket), ExtensionBloc, chat screen, extension accept/reject, goodbye flow, chat history + transcript screens, FCM setup + - control_center: free trial config, extension timeout config, early end toggles (mitra/customer) + - Docs: `requirement/phase3.md`, `requirement/phase3-plan.md` ## Domain Concepts diff --git a/backend/package-lock.json b/backend/package-lock.json index 7dd6a8c..d4afe1f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fastify/cors": "^9.0.1", "@fastify/sensible": "^5.6.0", + "@fastify/websocket": "^11.2.0", "dotenv": "^16.4.5", "fastify": "^4.28.1", "firebase-admin": "^12.2.0", @@ -88,6 +89,43 @@ "vary": "^1.1.2" } }, + "node_modules/@fastify/websocket": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz", + "integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.3", + "fastify-plugin": "^5.0.0", + "ws": "^8.16.0" + } + }, + "node_modules/@fastify/websocket/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@firebase/app-check-interop-types": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.2.tgz", @@ -854,7 +892,6 @@ "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", "license": "MIT", - "optional": true, "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", @@ -883,7 +920,6 @@ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", - "optional": true, "dependencies": { "once": "^1.4.0" } @@ -1943,7 +1979,6 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", - "optional": true, "dependencies": { "wrappy": "1" } @@ -2243,7 +2278,6 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", - "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -2467,15 +2501,13 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", - "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -2649,8 +2681,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/uuid": { "version": "10.0.0", @@ -2737,8 +2768,28 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "optional": true + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } }, "node_modules/xtend": { "version": "4.0.2", diff --git a/backend/package.json b/backend/package.json index 40f40e9..ac4b8c2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "dependencies": { "@fastify/cors": "^9.0.1", "@fastify/sensible": "^5.6.0", + "@fastify/websocket": "^11.2.0", "dotenv": "^16.4.5", "fastify": "^4.28.1", "firebase-admin": "^12.2.0", diff --git a/backend/src/app.public.js b/backend/src/app.public.js index f266264..c50caaf 100644 --- a/backend/src/app.public.js +++ b/backend/src/app.public.js @@ -8,22 +8,29 @@ 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 { sharedChatRoutes } from './routes/public/shared.chat.routes.js' import { errorHandler } from './plugins/error-handler.js' +import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js' export const buildPublicApp = async () => { const app = Fastify({ logger: true }) await app.register(cors, { origin: true }) await app.register(sensible) + await registerWebSocketPlugin(app) app.setErrorHandler(errorHandler) app.register(customerRoutes, { prefix: '/api/shared/customer' }) app.register(sharedConfigRoutes, { prefix: '/api/shared/config' }) + app.register(sharedChatRoutes, { prefix: '/api/shared' }) 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' }) + // WebSocket route (registered at app level, not prefixed) + registerWebSocketRoute(app) + return app } diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index 321af5f..493da0e 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -142,6 +142,125 @@ const migrate = async () => { 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 + ` + console.log('Migration complete.') await sql.end() } diff --git a/backend/src/plugins/websocket.js b/backend/src/plugins/websocket.js new file mode 100644 index 0000000..e3326c0 --- /dev/null +++ b/backend/src/plugins/websocket.js @@ -0,0 +1,151 @@ +import websocket from '@fastify/websocket' +import { verifyFirebaseToken } from './firebase.js' +import { getCustomerByFirebaseUid } from '../services/customer.service.js' +import { getMitraByFirebaseUid } from '../services/mitra.service.js' +import { subscribe, publish } from './valkey.js' + +// Track active WebSocket connections: sessionId → { customer, mitra } +const sessionConnections = new Map() + +// Track user → socket mapping for FCM fallback detection +const userSockets = new Map() // `customer:${id}` or `mitra:${id}` → socket + +export const registerWebSocketPlugin = async (app) => { + await app.register(websocket) +} + +export const isUserOnlineWs = (userType, userId) => { + const key = `${userType}:${userId}` + const socket = userSockets.get(key) + return socket && socket.readyState === 1 // WebSocket.OPEN +} + +export const getSessionConnections = (sessionId) => { + return sessionConnections.get(sessionId) || {} +} + +const sendToSocket = (socket, data) => { + if (socket && socket.readyState === 1) { + socket.send(JSON.stringify(data)) + return true + } + return false +} + +export const sendToSessionParticipant = (sessionId, userType, data) => { + const conns = sessionConnections.get(sessionId) + if (!conns) return false + return sendToSocket(conns[userType], data) +} + +export const sendToUser = (userType, userId, data) => { + const key = `${userType}:${userId}` + const socket = userSockets.get(key) + return sendToSocket(socket, data) +} + +export const registerWebSocketRoute = (app) => { + app.get('/api/shared/ws', { websocket: true }, (socket, request) => { + let authenticatedUser = null // { type: 'customer'|'mitra', id, sessionId } + let valkeyUnsubscribes = [] + + const send = (data) => sendToSocket(socket, data) + + socket.on('message', async (raw) => { + let msg + try { + msg = JSON.parse(raw.toString()) + } catch { + send({ type: 'error', message: 'Invalid JSON' }) + return + } + + // Handle auth message + if (msg.type === 'auth') { + try { + const decoded = await verifyFirebaseToken(msg.token) + const customer = await getCustomerByFirebaseUid(decoded.uid) + const mitra = customer ? null : await getMitraByFirebaseUid(decoded.uid) + + if (!customer && !mitra) { + send({ type: 'error', message: 'Account not found' }) + socket.close() + return + } + + const userType = customer ? 'customer' : 'mitra' + const userId = customer ? customer.id : mitra.id + const sessionId = msg.session_id + + authenticatedUser = { type: userType, id: userId, sessionId } + + // Register in connection maps + const userKey = `${userType}:${userId}` + userSockets.set(userKey, socket) + + if (sessionId) { + if (!sessionConnections.has(sessionId)) { + sessionConnections.set(sessionId, {}) + } + sessionConnections.get(sessionId)[userType] = socket + } + + // Subscribe to session channel for events from other services + if (sessionId) { + const unsub = subscribe(`session:${sessionId}:chat`, (data) => { + // Don't echo messages back to sender + if (data._sender_type === userType && data._sender_id === userId) return + const { _sender_type, _sender_id, ...payload } = data + send(payload) + }) + valkeyUnsubscribes.push(unsub) + } + + send({ type: 'auth_ok', user_type: userType, user_id: userId }) + } catch (err) { + send({ type: 'error', message: 'Authentication failed' }) + socket.close() + } + return + } + + // All other messages require authentication + if (!authenticatedUser) { + send({ type: 'error', message: 'Not authenticated. Send auth message first.' }) + return + } + + // Route message types to handlers via Valkey pub/sub + const { type, ...payload } = msg + await publish(`session:${authenticatedUser.sessionId}:incoming`, { + type, + ...payload, + _sender_type: authenticatedUser.type, + _sender_id: authenticatedUser.id, + _session_id: authenticatedUser.sessionId, + }) + }) + + socket.on('close', () => { + if (authenticatedUser) { + const userKey = `${authenticatedUser.type}:${authenticatedUser.id}` + userSockets.delete(userKey) + + if (authenticatedUser.sessionId) { + const conns = sessionConnections.get(authenticatedUser.sessionId) + if (conns) { + delete conns[authenticatedUser.type] + if (!conns.customer && !conns.mitra) { + sessionConnections.delete(authenticatedUser.sessionId) + } + } + } + } + + // Clean up Valkey subscriptions + for (const unsub of valkeyUnsubscribes) { + unsub() + } + }) + }) +} diff --git a/backend/src/routes/internal/config.routes.js b/backend/src/routes/internal/config.routes.js index f9a58df..590417e 100644 --- a/backend/src/routes/internal/config.routes.js +++ b/backend/src/routes/internal/config.routes.js @@ -1,6 +1,12 @@ import { authenticate, requirePermission } from '../../plugins/auth.js' import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js' -import { getAnonymityConfig, setAnonymityConfig, getMaxCustomersPerMitra, setMaxCustomersPerMitra } from '../../services/config.service.js' +import { + getAnonymityConfig, setAnonymityConfig, + getMaxCustomersPerMitra, setMaxCustomersPerMitra, + getFreeTrialConfig, setFreeTrialConfig, + getExtensionTimeoutConfig, setExtensionTimeoutConfig, + getEarlyEndConfig, setEarlyEndConfig, +} from '../../services/config.service.js' const attachCcUser = async (request, reply) => { const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) @@ -44,4 +50,55 @@ export const internalConfigRoutes = async (app) => { const config = await setMaxCustomersPerMitra(max_customers_per_mitra) return reply.send({ success: true, data: config }) }) + + // --- Phase 3: Free Trial --- + app.get('/free-trial', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], + }, async (request, reply) => { + const config = await getFreeTrialConfig() + return reply.send({ success: true, data: config }) + }) + + app.patch('/free-trial', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], + }, async (request, reply) => { + const { enabled, duration_minutes } = request.body ?? {} + const config = await setFreeTrialConfig({ enabled, duration_minutes }) + return reply.send({ success: true, data: config }) + }) + + // --- Phase 3: Extension Timeout --- + app.get('/extension-timeout', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], + }, async (request, reply) => { + const config = await getExtensionTimeoutConfig() + return reply.send({ success: true, data: config }) + }) + + app.patch('/extension-timeout', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], + }, async (request, reply) => { + const { extension_timeout_seconds } = request.body ?? {} + if (typeof extension_timeout_seconds !== 'number' || extension_timeout_seconds < 10) { + return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'Must be a number >= 10' } }) + } + const config = await setExtensionTimeoutConfig(extension_timeout_seconds) + return reply.send({ success: true, data: config }) + }) + + // --- Phase 3: Early End --- + app.get('/early-end', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], + }, async (request, reply) => { + const config = await getEarlyEndConfig() + return reply.send({ success: true, data: config }) + }) + + app.patch('/early-end', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], + }, async (request, reply) => { + const { mitra_enabled, customer_enabled } = request.body ?? {} + const config = await setEarlyEndConfig({ mitra_enabled, customer_enabled }) + return reply.send({ success: true, data: config }) + }) } diff --git a/backend/src/routes/public/client.chat.routes.js b/backend/src/routes/public/client.chat.routes.js index 7c00681..4cd9345 100644 --- a/backend/src/routes/public/client.chat.routes.js +++ b/backend/src/routes/public/client.chat.routes.js @@ -1,8 +1,10 @@ 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 { getActiveSessionByCustomer, endSession, getCustomerHistory } from '../../services/session.service.js' import { subscribe } from '../../plugins/valkey.js' +import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js' +import { requestExtension } from '../../services/extension.service.js' const resolveCustomer = async (request, reply) => { const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid) @@ -16,8 +18,48 @@ const resolveCustomer = async (request, reply) => { } export const clientChatRoutes = async (app) => { + // Get pricing tiers + free trial eligibility + app.get('/pricing', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + const pricing = await getPricingForCustomer(request.customer.id) + return reply.send({ success: true, data: pricing }) + }) + app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { - const session = await createPairingRequest(request.customer.id) + const { duration_minutes, price, is_free_trial } = request.body || {} + + // Validate selection + if (is_free_trial) { + const eligible = await isCustomerEligibleForFreeTrial(request.customer.id) + if (!eligible) { + return reply.code(403).send({ + success: false, + error: { code: 'FREE_TRIAL_INELIGIBLE', message: 'Not eligible for free trial' }, + }) + } + const freeTrial = await getFreeTrial() + const session = await createPairingRequest(request.customer.id, { + duration_minutes: freeTrial.duration_minutes, + price: 0, + is_free_trial: true, + }) + return reply.code(201).send({ success: true, data: session }) + } + + if (!duration_minutes || price === undefined) { + return reply.code(400).send({ + success: false, + error: { code: 'BAD_REQUEST', message: 'duration_minutes and price are required' }, + }) + } + + if (!isValidTier(duration_minutes, price)) { + return reply.code(400).send({ + success: false, + error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' }, + }) + } + + const session = await createPairingRequest(request.customer.id, { duration_minutes, price, is_free_trial: false }) return reply.code(201).send({ success: true, data: session }) }) @@ -72,4 +114,27 @@ export const clientChatRoutes = async (app) => { const session = await endSession(request.params.sessionId, 'customer') return reply.send({ success: true, data: session }) }) + + // Request session extension + app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + const { duration_minutes, price } = request.body || {} + if (!duration_minutes || price === undefined) { + return reply.code(400).send({ + success: false, + error: { code: 'BAD_REQUEST', message: 'duration_minutes and price are required' }, + }) + } + const extension = await requestExtension(request.params.sessionId, request.customer.id, { duration_minutes, price }) + return reply.send({ success: true, data: extension }) + }) + + // Chat history + app.get('/history', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + const { page, limit } = request.query + const history = await getCustomerHistory(request.customer.id, { + page: page ? parseInt(page) : 1, + limit: limit ? parseInt(limit) : 20, + }) + return reply.send({ success: true, data: history }) + }) } diff --git a/backend/src/routes/public/mitra.chat.routes.js b/backend/src/routes/public/mitra.chat.routes.js index 974e450..61dfcb3 100644 --- a/backend/src/routes/public/mitra.chat.routes.js +++ b/backend/src/routes/public/mitra.chat.routes.js @@ -1,8 +1,9 @@ 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 { getActiveSessionsByMitra, endSession, getMitraHistory } from '../../services/session.service.js' import { subscribe } from '../../plugins/valkey.js' +import { respondToExtension } from '../../services/extension.service.js' const resolveMitra = async (request, reply) => { const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid) @@ -66,4 +67,27 @@ export const mitraChatRoutes = async (app) => { const session = await endSession(request.params.sessionId, 'mitra') return reply.send({ success: true, data: session }) }) + + // Respond to extension request + app.post('/sessions/:sessionId/extend-response', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { + const { extension_id, accepted } = request.body || {} + if (!extension_id || accepted === undefined) { + return reply.code(400).send({ + success: false, + error: { code: 'BAD_REQUEST', message: 'extension_id and accepted are required' }, + }) + } + const extension = await respondToExtension(extension_id, request.params.sessionId, request.mitra.id, accepted) + return reply.send({ success: true, data: extension }) + }) + + // Chat history + app.get('/history', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { + const { page, limit } = request.query + const history = await getMitraHistory(request.mitra.id, { + page: page ? parseInt(page) : 1, + limit: limit ? parseInt(limit) : 20, + }) + return reply.send({ success: true, data: history }) + }) } diff --git a/backend/src/routes/public/shared.chat.routes.js b/backend/src/routes/public/shared.chat.routes.js new file mode 100644 index 0000000..01fe747 --- /dev/null +++ b/backend/src/routes/public/shared.chat.routes.js @@ -0,0 +1,79 @@ +import { authenticate } from '../../plugins/auth.js' +import { getCustomerByFirebaseUid } from '../../services/customer.service.js' +import { getMitraByFirebaseUid } from '../../services/mitra.service.js' +import { getMessages } from '../../services/chat.service.js' +import { getSessionClosures } from '../../services/closure.service.js' +import { registerDeviceToken } from '../../services/notification.service.js' + +const resolveUser = async (request, reply) => { + const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid) + if (customer) { + request.userType = 'customer' + request.userId = customer.id + return + } + const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid) + if (mitra) { + request.userType = 'mitra' + request.userId = mitra.id + return + } + return reply.code(404).send({ + success: false, + error: { code: 'ACCOUNT_NOT_FOUND', message: 'Account not found' }, + }) +} + +export const sharedChatRoutes = async (app) => { + // Get messages for a session (paginated) + app.get('/chat/:sessionId/messages', { preHandler: [authenticate, resolveUser] }, async (request, reply) => { + const { sessionId } = request.params + const { limit, before } = request.query + const messages = await getMessages(sessionId, { + limit: limit ? parseInt(limit) : 50, + before, + }) + return reply.send({ success: true, data: messages }) + }) + + // Get session info + app.get('/chat/:sessionId/info', { preHandler: [authenticate, resolveUser] }, async (request, reply) => { + const { sessionId } = request.params + const { getSessionById } = await import('../../services/session.service.js') + const session = await getSessionById(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 }) + }) + + // Get full transcript (read-only, for history) + app.get('/chat/:sessionId/transcript', { preHandler: [authenticate, resolveUser] }, async (request, reply) => { + const { sessionId } = request.params + const messages = await getMessages(sessionId, { limit: 10000 }) + const closures = await getSessionClosures(sessionId) + return reply.send({ success: true, data: { messages, closures } }) + }) + + // Register FCM device token + app.post('/device-token', { preHandler: [authenticate, resolveUser] }, async (request, reply) => { + const { token } = request.body + if (!token) { + return reply.code(400).send({ success: false, error: { code: 'BAD_REQUEST', message: 'Token is required' } }) + } + await registerDeviceToken(request.userType, request.userId, token) + return reply.send({ success: true }) + }) + + // Submit goodbye/closure message + app.post('/sessions/:sessionId/close-message', { preHandler: [authenticate, resolveUser] }, async (request, reply) => { + const { sessionId } = request.params + const { message } = request.body + if (!message) { + return reply.code(400).send({ success: false, error: { code: 'BAD_REQUEST', message: 'Message is required' } }) + } + const { submitClosureMessage } = await import('../../services/closure.service.js') + const closure = await submitClosureMessage(sessionId, request.userType, request.userId, message) + return reply.send({ success: true, data: closure }) + }) +} diff --git a/backend/src/server.js b/backend/src/server.js index 3f86167..790e653 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -3,6 +3,7 @@ import { buildPublicApp } from './app.public.js' import { buildInternalApp } from './app.internal.js' import { autoOfflineStaleMitras } from './services/mitra-status.service.js' import { initFirebase } from './plugins/firebase.js' +import { restoreActiveTimers } from './services/session-timer.service.js' const PUBLIC_PORT = process.env.PUBLIC_PORT || 3000 const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001 @@ -19,6 +20,9 @@ const start = async () => { await internalApp.listen({ port: INTERNAL_PORT, host: INTERNAL_HOST }) console.log(`Internal API listening on ${INTERNAL_HOST}:${INTERNAL_PORT}`) + // Restore session timers for active sessions (on server restart) + await restoreActiveTimers() + // Auto-offline mitras with stale heartbeat (every 30s) setInterval(async () => { try { diff --git a/backend/src/services/chat-handler.service.js b/backend/src/services/chat-handler.service.js new file mode 100644 index 0000000..a3d4239 --- /dev/null +++ b/backend/src/services/chat-handler.service.js @@ -0,0 +1,82 @@ +import { subscribe } from '../plugins/valkey.js' +import { sendMessage, markDelivered, markRead } from './chat.service.js' +import { initiateEarlyEnd } from './closure.service.js' +import { sendToSessionParticipant } from '../plugins/websocket.js' + +// Track typing throttle per session+user +const typingLastSent = new Map() +const TYPING_THROTTLE_MS = 2000 + +// Active session listeners: sessionId → unsubscribe +const sessionListeners = new Map() + +export const startSessionListener = (sessionId) => { + if (sessionListeners.has(sessionId)) return + + const unsub = subscribe(`session:${sessionId}:incoming`, async (data) => { + const { type, _sender_type, _sender_id, _session_id, ...payload } = data + + try { + switch (type) { + case 'message': + await sendMessage({ + sessionId: _session_id, + senderType: _sender_type, + senderId: _sender_id, + content: payload.content, + type: payload.message_type || 'text', + }) + break + + case 'typing': + handleTyping(_session_id, _sender_type) + break + + case 'delivered': + await markDelivered(_session_id, _sender_type, payload.message_ids) + break + + case 'read': + await markRead(_session_id, _sender_type, payload.message_ids) + break + + case 'early_end': + await initiateEarlyEnd(_session_id, _sender_type) + break + } + } catch (err) { + console.error(`[chat-handler] Error processing ${type}:`, err.message) + sendToSessionParticipant(_session_id, _sender_type, { + type: 'error', + message: err.message, + code: err.code, + }) + } + }) + + sessionListeners.set(sessionId, unsub) +} + +export const stopSessionListener = (sessionId) => { + const unsub = sessionListeners.get(sessionId) + if (unsub) { + unsub() + sessionListeners.delete(sessionId) + } +} + +const handleTyping = (sessionId, senderType) => { + const key = `${sessionId}:${senderType}` + const now = Date.now() + const lastSent = typingLastSent.get(key) || 0 + + if (now - lastSent < TYPING_THROTTLE_MS) return + + typingLastSent.set(key, now) + + const recipientType = senderType === 'customer' ? 'mitra' : 'customer' + sendToSessionParticipant(sessionId, recipientType, { + type: 'typing', + sender_type: senderType, + }) +} diff --git a/backend/src/services/chat.service.js b/backend/src/services/chat.service.js new file mode 100644 index 0000000..f926465 --- /dev/null +++ b/backend/src/services/chat.service.js @@ -0,0 +1,127 @@ +import { getDb } from '../db/client.js' +import { publish } from '../plugins/valkey.js' +import { isUserOnlineWs, sendToSessionParticipant } from '../plugins/websocket.js' +import { sendPushNotification } from './notification.service.js' + +const sql = getDb() + +export const sendMessage = async ({ sessionId, senderType, senderId, content, type = 'text' }) => { + // Verify session is active + const [session] = await sql` + SELECT id, customer_id, mitra_id, status FROM chat_sessions + WHERE id = ${sessionId} AND status = 'active' + ` + if (!session) { + throw Object.assign(new Error('Session is not active'), { + code: 'SESSION_NOT_ACTIVE', statusCode: 409, + }) + } + + // Save message + const [message] = await sql` + INSERT INTO chat_messages (session_id, sender_type, sender_id, type, content, status) + VALUES (${sessionId}, ${senderType}, ${senderId}, ${type}, ${content}, 'sent') + RETURNING id, session_id, sender_type, sender_id, type, content, status, created_at + ` + + // Send ack to sender + sendToSessionParticipant(sessionId, senderType, { + type: 'message_ack', + message_id: message.id, + status: 'sent', + created_at: message.created_at, + }) + + // Determine recipient + const recipientType = senderType === 'customer' ? 'mitra' : 'customer' + const recipientId = senderType === 'customer' ? session.mitra_id : session.customer_id + + // Try to send via WebSocket + const delivered = sendToSessionParticipant(sessionId, recipientType, { + type: 'message', + message_id: message.id, + sender_type: senderType, + content: message.content, + message_type: message.type, + created_at: message.created_at, + }) + + // If recipient not connected via WebSocket, send FCM push + if (!delivered && recipientId) { + await sendPushNotification(recipientType, recipientId, { + title: senderType === 'customer' ? 'Pesan baru dari Customer' : 'Pesan baru dari Bestie', + body: content.length > 100 ? content.substring(0, 100) + '...' : content, + data: { session_id: sessionId, type: 'chat_message' }, + }) + } + + return message +} + +export const markDelivered = async (sessionId, senderType, messageIds) => { + if (!messageIds || messageIds.length === 0) return + + await sql` + UPDATE chat_messages + SET status = 'delivered', delivered_at = NOW() + WHERE id = ANY(${messageIds}) + AND session_id = ${sessionId} + AND status = 'sent' + ` + + // Notify sender about delivery + const recipientType = senderType === 'customer' ? 'mitra' : 'customer' + sendToSessionParticipant(sessionId, recipientType, { + type: 'message_status', + message_ids: messageIds, + status: 'delivered', + }) +} + +export const markRead = async (sessionId, senderType, messageIds) => { + if (!messageIds || messageIds.length === 0) return + + await sql` + UPDATE chat_messages + SET status = 'read', read_at = NOW() + WHERE id = ANY(${messageIds}) + AND session_id = ${sessionId} + AND status IN ('sent', 'delivered') + ` + + // Notify sender about read + const recipientType = senderType === 'customer' ? 'mitra' : 'customer' + sendToSessionParticipant(sessionId, recipientType, { + type: 'message_status', + message_ids: messageIds, + status: 'read', + }) +} + +export const getMessages = async (sessionId, { limit = 50, before } = {}) => { + const conditions = before + ? sql`AND created_at < ${before}` + : sql`` + + const messages = await sql` + SELECT id, session_id, sender_type, sender_id, type, content, status, delivered_at, read_at, created_at + FROM chat_messages + WHERE session_id = ${sessionId} + ${conditions} + ORDER BY created_at DESC + LIMIT ${limit} + ` + return messages.reverse() // Return in chronological order +} + +export const getUndeliveredMessages = async (sessionId, recipientType) => { + const senderType = recipientType === 'customer' ? 'mitra' : 'customer' + return sql` + SELECT id, session_id, sender_type, sender_id, type, content, status, created_at + FROM chat_messages + WHERE session_id = ${sessionId} + AND sender_type = ${senderType} + AND status = 'sent' + ORDER BY created_at ASC + ` +} diff --git a/backend/src/services/closure.service.js b/backend/src/services/closure.service.js new file mode 100644 index 0000000..3a919fc --- /dev/null +++ b/backend/src/services/closure.service.js @@ -0,0 +1,106 @@ +import { getDb } from '../db/client.js' +import { publish } from '../plugins/valkey.js' +import { clearSessionTimer } from './session-timer.service.js' +import { sendToSessionParticipant } from '../plugins/websocket.js' + +const sql = getDb() + +export const submitClosureMessage = async (sessionId, userType, userId, message) => { + // Verify session is in closing or active state (for early end) + const [session] = await sql` + SELECT id, status FROM chat_sessions + WHERE id = ${sessionId} AND status IN ('closing', 'active', 'extending') + ` + if (!session) { + throw Object.assign(new Error('Session not found or already completed'), { + code: 'SESSION_NOT_ACTIVE', statusCode: 409, + }) + } + + // Save closure message + const [closure] = await sql` + INSERT INTO session_closures (session_id, user_type, user_id, message) + VALUES (${sessionId}, ${userType}, ${userId}, ${message}) + ON CONFLICT DO NOTHING + RETURNING id, session_id, user_type, message, created_at + ` + + // Check if both parties have submitted + const closures = await sql` + SELECT user_type FROM session_closures WHERE session_id = ${sessionId} + ` + const hasCustomer = closures.some((c) => c.user_type === 'customer') + const hasMitra = closures.some((c) => c.user_type === 'mitra') + + if (hasCustomer && hasMitra) { + // Both submitted — complete the session + await completeSession(sessionId) + } + + return closure +} + +export const completeSession = async (sessionId) => { + clearSessionTimer(sessionId) + + const [session] = await sql` + UPDATE chat_sessions + SET status = 'completed', ended_at = NOW(), ended_by = 'system' + WHERE id = ${sessionId} AND status IN ('closing', 'active', 'extending') + RETURNING id, customer_id, mitra_id, status, ended_at + ` + if (!session) return null + + // Notify both parties + const data = { type: 'session_completed', session_id: sessionId } + sendToSessionParticipant(sessionId, 'customer', data) + sendToSessionParticipant(sessionId, 'mitra', data) + + await publish(`session:${sessionId}:status`, { type: 'session_ended', session_id: sessionId }) + + return session +} + +export const initiateEarlyEnd = async (sessionId, userType) => { + // Check if early end is enabled for this user type + const configKey = userType === 'mitra' ? 'early_end_mitra_enabled' : 'early_end_customer_enabled' + const [configRow] = await sql`SELECT value FROM app_config WHERE key = ${configKey}` + const enabled = configRow?.value?.value ?? false + + if (!enabled) { + throw Object.assign(new Error('Early end is not enabled'), { + code: 'EARLY_END_DISABLED', statusCode: 403, + }) + } + + // Move session to closing + const [session] = await sql` + UPDATE chat_sessions + SET status = 'closing', ended_by = ${userType} + WHERE id = ${sessionId} AND status = 'active' + RETURNING id, customer_id, mitra_id + ` + if (!session) { + throw Object.assign(new Error('Session not active'), { + code: 'SESSION_NOT_ACTIVE', statusCode: 409, + }) + } + + clearSessionTimer(sessionId) + + // Notify both parties to enter closure flow + const data = { type: 'session_closing', session_id: sessionId, ended_by: userType } + sendToSessionParticipant(sessionId, 'customer', data) + sendToSessionParticipant(sessionId, 'mitra', data) + + return session +} + +export const getSessionClosures = async (sessionId) => { + return sql` + SELECT user_type, message, created_at + FROM session_closures + WHERE session_id = ${sessionId} + ORDER BY created_at ASC + ` +} diff --git a/backend/src/services/config.service.js b/backend/src/services/config.service.js index b3047e4..aaad759 100644 --- a/backend/src/services/config.service.js +++ b/backend/src/services/config.service.js @@ -29,3 +29,73 @@ export const setMaxCustomersPerMitra = async (value) => { ` return { max_customers_per_mitra: value } } + +// --- Phase 3 config --- + +export const getFreeTrialConfig = async () => { + const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'` + const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'` + return { + enabled: enabledRow?.value?.value ?? false, + duration_minutes: durationRow?.value?.value ?? 5, + } +} + +export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => { + if (enabled !== undefined) { + await sql` + INSERT INTO app_config (key, value, updated_at) + VALUES ('free_trial_enabled', ${sql.json({ value: enabled })}, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + ` + } + if (duration_minutes !== undefined) { + await sql` + INSERT INTO app_config (key, value, updated_at) + VALUES ('free_trial_duration_minutes', ${sql.json({ value: duration_minutes })}, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + ` + } + return getFreeTrialConfig() +} + +export const getExtensionTimeoutConfig = async () => { + const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_timeout_seconds'` + return { extension_timeout_seconds: row?.value?.value ?? 60 } +} + +export const setExtensionTimeoutConfig = async (seconds) => { + await sql` + INSERT INTO app_config (key, value, updated_at) + VALUES ('extension_timeout_seconds', ${sql.json({ value: seconds })}, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + ` + return { extension_timeout_seconds: seconds } +} + +export const getEarlyEndConfig = async () => { + const [mitraRow] = await sql`SELECT value FROM app_config WHERE key = 'early_end_mitra_enabled'` + const [customerRow] = await sql`SELECT value FROM app_config WHERE key = 'early_end_customer_enabled'` + return { + mitra_enabled: mitraRow?.value?.value ?? false, + customer_enabled: customerRow?.value?.value ?? false, + } +} + +export const setEarlyEndConfig = async ({ mitra_enabled, customer_enabled }) => { + if (mitra_enabled !== undefined) { + await sql` + INSERT INTO app_config (key, value, updated_at) + VALUES ('early_end_mitra_enabled', ${sql.json({ value: mitra_enabled })}, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + ` + } + if (customer_enabled !== undefined) { + await sql` + INSERT INTO app_config (key, value, updated_at) + VALUES ('early_end_customer_enabled', ${sql.json({ value: customer_enabled })}, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + ` + } + return getEarlyEndConfig() +} diff --git a/backend/src/services/extension.service.js b/backend/src/services/extension.service.js new file mode 100644 index 0000000..08c4fec --- /dev/null +++ b/backend/src/services/extension.service.js @@ -0,0 +1,159 @@ +import { getDb } from '../db/client.js' +import { publish } from '../plugins/valkey.js' +import { sendToSessionParticipant } from '../plugins/websocket.js' +import { extendSessionTimer } from './session-timer.service.js' + +const sql = getDb() + +// Extension timeout map: extensionId → timeoutId +const extensionTimeouts = new Map() + +const getExtensionTimeout = async () => { + const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_timeout_seconds'` + return (row?.value?.value ?? 60) * 1000 // Convert to ms +} + +export const requestExtension = async (sessionId, customerId, { duration_minutes, price }) => { + // Verify session belongs to customer and just expired + const [session] = await sql` + SELECT id, customer_id, mitra_id, status FROM chat_sessions + WHERE id = ${sessionId} AND customer_id = ${customerId} + ` + if (!session) { + throw Object.assign(new Error('Session not found'), { code: 'NOT_FOUND', statusCode: 404 }) + } + + // Create extension record + const [extension] = await sql` + INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status) + VALUES (${sessionId}, ${duration_minutes}, ${price}, 'pending') + RETURNING id, session_id, requested_duration_minutes, requested_price, status, requested_at + ` + + // Pause the session + await sql`UPDATE chat_sessions SET status = 'extending' WHERE id = ${sessionId}` + + // Notify mitra + sendToSessionParticipant(sessionId, 'mitra', { + type: 'extension_request', + extension_id: extension.id, + session_id: sessionId, + duration_minutes, + price, + }) + + // Notify customer that chat is paused + sendToSessionParticipant(sessionId, 'customer', { + type: 'session_paused', + session_id: sessionId, + reason: 'extension_pending', + }) + + // Start timeout + const timeoutMs = await getExtensionTimeout() + const timeoutId = setTimeout(async () => { + try { + await timeoutExtension(extension.id, sessionId) + } catch (_) {} + }, timeoutMs) + extensionTimeouts.set(extension.id, timeoutId) + + return extension +} + +export const respondToExtension = async (extensionId, sessionId, mitraId, accepted) => { + const status = accepted ? 'accepted' : 'rejected' + + const [extension] = await sql` + UPDATE session_extensions + SET status = ${status}, responded_at = NOW() + WHERE id = ${extensionId} AND status = 'pending' + RETURNING id, session_id, requested_duration_minutes, requested_price, status + ` + + if (!extension) { + throw Object.assign(new Error('Extension not found or already resolved'), { + code: 'EXTENSION_RESOLVED', statusCode: 409, + }) + } + + // Clear timeout + const timeoutId = extensionTimeouts.get(extensionId) + if (timeoutId) { + clearTimeout(timeoutId) + extensionTimeouts.delete(extensionId) + } + + if (accepted) { + // Extend the session + await extendSessionTimer(extension.session_id, extension.requested_duration_minutes) + + // Resume session + await sql`UPDATE chat_sessions SET status = 'active' WHERE id = ${extension.session_id}` + + // Record transaction + await sql` + INSERT INTO customer_transactions (customer_id, session_id, type, amount) + SELECT customer_id, id, 'extension', ${extension.requested_price} + FROM chat_sessions WHERE id = ${extension.session_id} + ` + + // Notify both parties + sendToSessionParticipant(sessionId, 'customer', { + type: 'extension_response', + accepted: true, + duration_minutes: extension.requested_duration_minutes, + }) + sendToSessionParticipant(sessionId, 'mitra', { + type: 'session_resumed', + session_id: sessionId, + }) + } else { + // Rejected — proceed to closure + await sql`UPDATE chat_sessions SET status = 'closing' WHERE id = ${extension.session_id}` + + sendToSessionParticipant(sessionId, 'customer', { + type: 'extension_response', + accepted: false, + }) + sendToSessionParticipant(sessionId, 'mitra', { + type: 'session_closing', + session_id: sessionId, + }) + sendToSessionParticipant(sessionId, 'customer', { + type: 'session_closing', + session_id: sessionId, + }) + } + + return extension +} + +const timeoutExtension = async (extensionId, sessionId) => { + extensionTimeouts.delete(extensionId) + + const [extension] = await sql` + UPDATE session_extensions + SET status = 'timeout', responded_at = NOW() + WHERE id = ${extensionId} AND status = 'pending' + RETURNING id, session_id + ` + if (!extension) return + + // Timeout = proceed to closure + await sql`UPDATE chat_sessions SET status = 'closing' WHERE id = ${extension.session_id}` + + sendToSessionParticipant(sessionId, 'customer', { + type: 'extension_response', + accepted: false, + reason: 'timeout', + }) + sendToSessionParticipant(sessionId, 'mitra', { + type: 'session_closing', + session_id: sessionId, + }) + sendToSessionParticipant(sessionId, 'customer', { + type: 'session_closing', + session_id: sessionId, + }) +} diff --git a/backend/src/services/notification.service.js b/backend/src/services/notification.service.js new file mode 100644 index 0000000..51e76c3 --- /dev/null +++ b/backend/src/services/notification.service.js @@ -0,0 +1,52 @@ +import admin from 'firebase-admin' +import { getDb } from '../db/client.js' + +const sql = getDb() + +export const registerDeviceToken = async (userType, userId, fcmToken) => { + const table = userType === 'customer' ? 'customers' : 'mitras' + await sql` + UPDATE ${sql(table)} + SET fcm_token = ${fcmToken} + WHERE id = ${userId} + ` +} + +export const sendPushNotification = async (recipientType, recipientId, { title, body, data = {} }) => { + const table = recipientType === 'customer' ? 'customers' : 'mitras' + const [user] = await sql` + SELECT fcm_token FROM ${sql(table)} WHERE id = ${recipientId} + ` + + if (!user?.fcm_token) return false + + try { + await admin.messaging().send({ + token: user.fcm_token, + notification: { title, body }, + data: { + ...Object.fromEntries( + Object.entries(data).map(([k, v]) => [k, String(v)]) + ), + click_action: 'FLUTTER_NOTIFICATION_CLICK', + }, + android: { + priority: 'high', + notification: { channelId: 'chat_messages' }, + }, + apns: { + payload: { + aps: { sound: 'default', badge: 1 }, + }, + }, + }) + return true + } catch (err) { + console.error(`[FCM] Failed to send to ${recipientType}:${recipientId}:`, err.message) + // Clear invalid token + if (err.code === 'messaging/registration-token-not-registered') { + await sql`UPDATE ${sql(table)} SET fcm_token = NULL WHERE id = ${recipientId}` + } + return false + } +} diff --git a/backend/src/services/pairing.service.js b/backend/src/services/pairing.service.js index 31cf8c4..3bd3b92 100644 --- a/backend/src/services/pairing.service.js +++ b/backend/src/services/pairing.service.js @@ -1,6 +1,8 @@ import { getDb } from '../db/client.js' import { getMaxCustomersPerMitra } from './config.service.js' import { publish } from '../plugins/valkey.js' +import { startSessionTimer } from './session-timer.service.js' +import { startSessionListener } from './chat-handler.service.js' const sql = getDb() @@ -23,7 +25,7 @@ export const findAvailableMitras = async () => { return mitras } -export const createPairingRequest = async (customerId) => { +export const createPairingRequest = async (customerId, { duration_minutes, price, is_free_trial } = {}) => { // Check for existing active session or request const [existing] = await sql` SELECT id, status FROM chat_sessions @@ -43,11 +45,11 @@ export const createPairingRequest = async (customerId) => { }) } - // Create session + // Create session with duration/price const [session] = await sql` - INSERT INTO chat_sessions (customer_id, status) - VALUES (${customerId}, 'pending_acceptance') - RETURNING id, customer_id, status, created_at + INSERT INTO chat_sessions (customer_id, status, duration_minutes, price, is_free_trial) + VALUES (${customerId}, 'pending_acceptance', ${duration_minutes || null}, ${price || 0}, ${is_free_trial || false}) + RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, created_at ` // Create notifications for all available mitras @@ -111,13 +113,35 @@ export const acceptPairingRequest = async (sessionId, mitraId) => { pairingTimeouts.delete(sessionId) } - // Auto-skip payment for now: move to active + // Auto-skip payment for now: move to active and set expires_at const [activeSession] = await sql` - UPDATE chat_sessions SET status = 'active' + UPDATE chat_sessions + SET status = 'active', + expires_at = CASE + WHEN duration_minutes IS NOT NULL THEN NOW() + (duration_minutes || ' minutes')::interval + ELSE NULL + END WHERE id = ${sessionId} - RETURNING id, customer_id, mitra_id, status, paired_at + RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_free_trial, expires_at ` + // Record transaction + if (activeSession.duration_minutes) { + const txType = activeSession.is_free_trial ? 'free_trial' : 'paid' + await sql` + INSERT INTO customer_transactions (customer_id, session_id, type, amount) + VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0}) + ` + } + + // Start session timer if duration is set + if (activeSession.expires_at) { + startSessionTimer(sessionId, activeSession.expires_at) + } + + // Start chat message listener for this session + startSessionListener(sessionId) + // Get mitra display name for customer notification const [mitra] = await sql` SELECT display_name FROM mitras WHERE id = ${mitraId} diff --git a/backend/src/services/pricing.service.js b/backend/src/services/pricing.service.js new file mode 100644 index 0000000..6b0b233 --- /dev/null +++ b/backend/src/services/pricing.service.js @@ -0,0 +1,54 @@ +import { getDb } from '../db/client.js' + +const sql = getDb() + +// Mock price tiers (will come from Control Center config later) +const PRICE_TIERS = [ + { 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' }, +] + +export const getPriceTiers = () => PRICE_TIERS + +export const isValidTier = (durationMinutes, price) => { + return PRICE_TIERS.some( + (t) => t.duration_minutes === durationMinutes && t.price === price + ) +} + +export const getFreeTrial = async () => { + const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'` + const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'` + return { + enabled: enabledRow?.value?.value ?? false, + duration_minutes: durationRow?.value?.value ?? 5, + } +} + +export const isCustomerEligibleForFreeTrial = async (customerId) => { + const freeTrial = await getFreeTrial() + if (!freeTrial.enabled) return false + + const [tx] = await sql` + SELECT id FROM customer_transactions + WHERE customer_id = ${customerId} + LIMIT 1 + ` + return !tx // Eligible only if no transactions at all +} + +export const getPricingForCustomer = async (customerId) => { + const tiers = getPriceTiers() + const freeTrialEligible = await isCustomerEligibleForFreeTrial(customerId) + const freeTrial = await getFreeTrial() + + return { + tiers, + free_trial: freeTrialEligible + ? { eligible: true, duration_minutes: freeTrial.duration_minutes } + : { eligible: false }, + } +} diff --git a/backend/src/services/session-timer.service.js b/backend/src/services/session-timer.service.js new file mode 100644 index 0000000..9e914f3 --- /dev/null +++ b/backend/src/services/session-timer.service.js @@ -0,0 +1,100 @@ +import { getDb } from '../db/client.js' +import { publish } from '../plugins/valkey.js' +import { sendToSessionParticipant } from '../plugins/websocket.js' + +const sql = getDb() + +// Active session timers: sessionId → { warningTimeout, expiryTimeout } +const sessionTimers = new Map() + +export const startSessionTimer = (sessionId, expiresAt) => { + const now = Date.now() + const expiresMs = new Date(expiresAt).getTime() + const warningMs = expiresMs - 60_000 // 1 minute before expiry + + // Clear any existing timers + clearSessionTimer(sessionId) + + const timers = {} + + // Warning timer (1 min before expiry) + if (warningMs > now) { + timers.warningTimeout = setTimeout(() => { + onSessionWarning(sessionId) + }, warningMs - now) + } + + // Expiry timer + if (expiresMs > now) { + timers.expiryTimeout = setTimeout(() => { + onSessionExpired(sessionId) + }, expiresMs - now) + } else { + // Already expired + onSessionExpired(sessionId) + return + } + + sessionTimers.set(sessionId, timers) +} + +export const clearSessionTimer = (sessionId) => { + const timers = sessionTimers.get(sessionId) + if (timers) { + if (timers.warningTimeout) clearTimeout(timers.warningTimeout) + if (timers.expiryTimeout) clearTimeout(timers.expiryTimeout) + sessionTimers.delete(sessionId) + } +} + +export const extendSessionTimer = async (sessionId, additionalMinutes) => { + const [session] = await sql` + UPDATE chat_sessions + SET expires_at = expires_at + ${additionalMinutes + ' minutes'}::interval, + extended_minutes = extended_minutes + ${additionalMinutes} + WHERE id = ${sessionId} + RETURNING id, expires_at + ` + if (session) { + startSessionTimer(sessionId, session.expires_at) + } + return session +} + +const onSessionWarning = (sessionId) => { + const data = { type: 'session_timer', remaining_seconds: 60, session_id: sessionId } + sendToSessionParticipant(sessionId, 'customer', data) + sendToSessionParticipant(sessionId, 'mitra', data) +} + +const onSessionExpired = async (sessionId) => { + clearSessionTimer(sessionId) + + // Check session is still active + const [session] = await sql` + SELECT id, status FROM chat_sessions WHERE id = ${sessionId} AND status = 'active' + ` + if (!session) return + + // Notify both parties + const data = { type: 'session_expired', session_id: sessionId } + sendToSessionParticipant(sessionId, 'customer', data) + sendToSessionParticipant(sessionId, 'mitra', data) + + // Also publish via Valkey for any listeners + await publish(`session:${sessionId}:status`, data) +} + +// Restore timers for active sessions on server restart +export const restoreActiveTimers = async () => { + const activeSessions = await sql` + SELECT id, expires_at FROM chat_sessions + WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at > NOW() + ` + for (const session of activeSessions) { + startSessionTimer(session.id, session.expires_at) + } + if (activeSessions.length > 0) { + console.log(`Restored ${activeSessions.length} session timer(s)`) + } +} diff --git a/backend/src/services/session.service.js b/backend/src/services/session.service.js index e21600b..2f942f9 100644 --- a/backend/src/services/session.service.js +++ b/backend/src/services/session.service.js @@ -6,11 +6,12 @@ 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, + cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes, 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') + AND cs.status IN ('active', 'pending_payment', 'extending', 'closing') ORDER BY cs.created_at DESC LIMIT 1 ` return session @@ -19,11 +20,12 @@ export const getActiveSessionByCustomer = async (customerId) => { export const getActiveSessionsByMitra = async (mitraId) => { const sessions = await sql` SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at, + cs.duration_minutes, cs.expires_at, cs.extended_minutes, 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') + AND cs.status IN ('active', 'pending_payment', 'extending', 'closing') ORDER BY cs.created_at DESC ` return sessions @@ -138,6 +140,7 @@ export const listSessions = async ({ page = 1, limit = 20, status } = {}) => { 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, + cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes, c.display_name AS customer_display_name, m.display_name AS mitra_display_name FROM chat_sessions cs @@ -147,3 +150,45 @@ export const getSessionById = async (sessionId) => { ` return session } + +export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => { + const offset = (page - 1) * limit + const items = await sql` + SELECT cs.id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, + cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes, + m.display_name AS mitra_display_name, + (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'mitra' LIMIT 1) AS mitra_closure_message, + (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'customer' LIMIT 1) AS customer_closure_message + FROM chat_sessions cs + LEFT JOIN mitras m ON m.id = cs.mitra_id + WHERE cs.customer_id = ${customerId} + AND cs.status = 'completed' + ORDER BY cs.ended_at DESC + LIMIT ${limit} OFFSET ${offset} + ` + const [{ count }] = await sql` + SELECT COUNT(*) FROM chat_sessions WHERE customer_id = ${customerId} AND status = 'completed' + ` + return { items, total: Number(count), page, limit } +} + +export const getMitraHistory = async (mitraId, { page = 1, limit = 20 } = {}) => { + const offset = (page - 1) * limit + const items = await sql` + SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, + cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes, + c.display_name AS customer_display_name, + (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'mitra' LIMIT 1) AS mitra_closure_message, + (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'customer' LIMIT 1) AS customer_closure_message + FROM chat_sessions cs + INNER JOIN customers c ON c.id = cs.customer_id + WHERE cs.mitra_id = ${mitraId} + AND cs.status = 'completed' + ORDER BY cs.ended_at DESC + LIMIT ${limit} OFFSET ${offset} + ` + const [{ count }] = await sql` + SELECT COUNT(*) FROM chat_sessions WHERE mitra_id = ${mitraId} AND status = 'completed' + ` + return { items, total: Number(count), page, limit } +} diff --git a/client_app/lib/core/chat/chat_bloc.dart b/client_app/lib/core/chat/chat_bloc.dart new file mode 100644 index 0000000..14a029d --- /dev/null +++ b/client_app/lib/core/chat/chat_bloc.dart @@ -0,0 +1,385 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:equatable/equatable.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import '../api/api_client.dart'; + +// Events +abstract class ChatEvent extends Equatable { + @override + List get props => []; +} + +class ConnectChat extends ChatEvent { + final String sessionId; + ConnectChat(this.sessionId); + @override + List get props => [sessionId]; +} + +class DisconnectChat extends ChatEvent {} + +class SendMessage extends ChatEvent { + final String content; + SendMessage(this.content); + @override + List get props => [content]; +} + +class SendTyping extends ChatEvent {} + +class _MessageReceived extends ChatEvent { + final Map data; + _MessageReceived(this.data); + @override + List get props => [data]; +} + +class _ConnectionError extends ChatEvent {} + +class MarkMessagesDelivered extends ChatEvent { + final List messageIds; + MarkMessagesDelivered(this.messageIds); + @override + List get props => [messageIds]; +} + +class MarkMessagesRead extends ChatEvent { + final List messageIds; + MarkMessagesRead(this.messageIds); + @override + List get props => [messageIds]; +} + +// States +abstract class ChatState extends Equatable { + @override + List get props => []; +} + +class ChatInitial extends ChatState {} +class ChatConnecting extends ChatState {} + +class ChatConnected extends ChatState { + final List messages; + final bool isOtherTyping; + final int? remainingSeconds; + final bool sessionExpired; + final bool sessionPaused; + final bool sessionClosing; + final Map? extensionResponse; + + ChatConnected({ + required this.messages, + this.isOtherTyping = false, + this.remainingSeconds, + this.sessionExpired = false, + this.sessionPaused = false, + this.sessionClosing = false, + this.extensionResponse, + }); + + ChatConnected copyWith({ + List? messages, + bool? isOtherTyping, + int? remainingSeconds, + bool? sessionExpired, + bool? sessionPaused, + bool? sessionClosing, + Map? extensionResponse, + }) { + return ChatConnected( + messages: messages ?? this.messages, + isOtherTyping: isOtherTyping ?? this.isOtherTyping, + remainingSeconds: remainingSeconds ?? this.remainingSeconds, + sessionExpired: sessionExpired ?? this.sessionExpired, + sessionPaused: sessionPaused ?? this.sessionPaused, + sessionClosing: sessionClosing ?? this.sessionClosing, + extensionResponse: extensionResponse ?? this.extensionResponse, + ); + } + + @override + List get props => [messages, isOtherTyping, remainingSeconds, sessionExpired, sessionPaused, sessionClosing, extensionResponse]; +} + +class ChatError extends ChatState { + final String message; + ChatError(this.message); + @override + List get props => [message]; +} + +// Message model +class ChatMessage { + final String id; + final String senderType; + final String content; + final String type; + final String status; // sending, sent, delivered, read + final DateTime createdAt; + + ChatMessage({ + required this.id, + required this.senderType, + required this.content, + this.type = 'text', + this.status = 'sent', + required this.createdAt, + }); + + ChatMessage copyWith({String? status}) { + return ChatMessage( + id: id, + senderType: senderType, + content: content, + type: type, + status: status ?? this.status, + createdAt: createdAt, + ); + } +} + +// Bloc +class ChatBloc extends Bloc { + final ApiClient apiClient; + WebSocketChannel? _channel; + StreamSubscription? _wsSubscription; + Timer? _typingTimer; + + ChatBloc({required this.apiClient}) : super(ChatInitial()) { + on(_onConnect); + on(_onDisconnect); + on(_onSendMessage); + on(_onSendTyping); + on<_MessageReceived>(_onMessageReceived); + on<_ConnectionError>(_onConnectionError); + on(_onMarkDelivered); + on(_onMarkRead); + } + + Future _onConnect(ConnectChat event, Emitter emit) async { + emit(ChatConnecting()); + + try { + // Load existing messages from API + final response = await apiClient.get( + '/api/shared/chat/${event.sessionId}/messages', + ); + final messagesData = response['data'] as List; + final messages = messagesData.map((m) => ChatMessage( + id: m['id'] as String, + senderType: m['sender_type'] as String, + content: m['content'] as String, + type: m['type'] as String? ?? 'text', + status: m['status'] as String? ?? 'sent', + createdAt: DateTime.parse(m['created_at'] as String), + )).toList(); + + // Connect WebSocket + final user = FirebaseAuth.instance.currentUser; + final token = await user?.getIdToken(); + final wsUrl = ApiClient.baseUrl + .replaceFirst('https://', 'wss://') + .replaceFirst('http://', 'ws://'); + _channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws')); + + _wsSubscription = _channel!.stream.listen( + (raw) { + final data = jsonDecode(raw as String) as Map; + add(_MessageReceived(data)); + }, + onError: (_) => add(_ConnectionError()), + onDone: () => add(_ConnectionError()), + ); + + // Send auth message + _channel!.sink.add(jsonEncode({ + 'type': 'auth', + 'token': token, + 'session_id': event.sessionId, + })); + + emit(ChatConnected(messages: messages)); + } catch (e) { + emit(ChatError('Gagal terhubung ke chat.')); + } + } + + void _onDisconnect(DisconnectChat event, Emitter emit) { + _cleanup(); + emit(ChatInitial()); + } + + void _onSendMessage(SendMessage event, Emitter emit) { + if (state is! ChatConnected || _channel == null) return; + final current = state as ChatConnected; + + // Add message locally with 'sending' status + final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; + final msg = ChatMessage( + id: tempId, + senderType: 'customer', + content: event.content, + status: 'sending', + createdAt: DateTime.now(), + ); + + emit(current.copyWith(messages: [...current.messages, msg])); + + _channel!.sink.add(jsonEncode({ + 'type': 'message', + 'content': event.content, + '_temp_id': tempId, + })); + } + + void _onSendTyping(SendTyping event, Emitter emit) { + if (_channel == null) return; + _channel!.sink.add(jsonEncode({'type': 'typing'})); + } + + void _onMarkDelivered(MarkMessagesDelivered event, Emitter emit) { + if (_channel == null) return; + _channel!.sink.add(jsonEncode({ + 'type': 'delivered', + 'message_ids': event.messageIds, + })); + } + + void _onMarkRead(MarkMessagesRead event, Emitter emit) { + if (_channel == null) return; + _channel!.sink.add(jsonEncode({ + 'type': 'read', + 'message_ids': event.messageIds, + })); + } + + void _onMessageReceived(_MessageReceived event, Emitter emit) { + if (state is! ChatConnected) return; + final current = state as ChatConnected; + final data = event.data; + final type = data['type'] as String?; + + switch (type) { + case 'auth_ok': + // Already connected + break; + + case 'message': + final msg = ChatMessage( + id: data['message_id'] as String, + senderType: data['sender_type'] as String, + content: data['content'] as String, + type: data['message_type'] as String? ?? 'text', + status: 'sent', + createdAt: DateTime.parse(data['created_at'] as String), + ); + emit(current.copyWith(messages: [...current.messages, msg])); + // Auto-acknowledge delivery + add(MarkMessagesDelivered([msg.id])); + break; + + case 'message_ack': + final messageId = data['message_id'] as String; + final status = data['status'] as String; + final updatedMessages = current.messages.map((m) { + if (m.status == 'sending') { + return m.copyWith(status: status); + } + return m; + }).toList(); + // Replace temp ID with real ID + final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == 'customer'); + if (idx >= 0) { + final old = updatedMessages[idx]; + updatedMessages[idx] = ChatMessage( + id: messageId, + senderType: old.senderType, + content: old.content, + type: old.type, + status: status, + createdAt: old.createdAt, + ); + } + emit(current.copyWith(messages: updatedMessages)); + break; + + case 'message_status': + final messageIds = (data['message_ids'] as List).cast(); + final status = data['status'] as String; + final updatedMessages = current.messages.map((m) { + if (messageIds.contains(m.id)) { + return m.copyWith(status: status); + } + return m; + }).toList(); + emit(current.copyWith(messages: updatedMessages)); + break; + + case 'typing': + emit(current.copyWith(isOtherTyping: true)); + _typingTimer?.cancel(); + _typingTimer = Timer(const Duration(seconds: 3), () { + if (state is ChatConnected) { + emit((state as ChatConnected).copyWith(isOtherTyping: false)); + } + }); + break; + + case 'session_timer': + final remaining = data['remaining_seconds'] as int?; + emit(current.copyWith(remainingSeconds: remaining)); + break; + + case 'session_expired': + emit(current.copyWith(sessionExpired: true)); + break; + + case 'session_paused': + emit(current.copyWith(sessionPaused: true)); + break; + + case 'session_resumed': + emit(current.copyWith(sessionPaused: false, sessionExpired: false)); + break; + + case 'session_closing': + emit(current.copyWith(sessionClosing: true)); + break; + + case 'extension_response': + emit(current.copyWith(extensionResponse: data)); + break; + + case 'session_completed': + _cleanup(); + break; + + case 'error': + // Keep connected but show error + break; + } + } + + void _onConnectionError(_ConnectionError event, Emitter emit) { + // Could implement reconnection logic here + } + + void _cleanup() { + _wsSubscription?.cancel(); + _wsSubscription = null; + _channel?.sink.close(); + _channel = null; + _typingTimer?.cancel(); + _typingTimer = null; + } + + @override + Future close() { + _cleanup(); + return super.close(); + } +} diff --git a/client_app/lib/core/chat/chat_opening_bloc.dart b/client_app/lib/core/chat/chat_opening_bloc.dart new file mode 100644 index 0000000..0e2e5f0 --- /dev/null +++ b/client_app/lib/core/chat/chat_opening_bloc.dart @@ -0,0 +1,87 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../api/api_client.dart'; + +// Events +abstract class ChatOpeningEvent extends Equatable { + @override + List get props => []; +} + +class LoadPricing extends ChatOpeningEvent {} + +// States +abstract class ChatOpeningState extends Equatable { + @override + List get props => []; +} + +class PricingInitial extends ChatOpeningState {} +class PricingLoading extends ChatOpeningState {} + +class PricingLoaded extends ChatOpeningState { + final List tiers; + final bool freeTrialEligible; + final int freeTrialDurationMinutes; + + PricingLoaded({ + required this.tiers, + required this.freeTrialEligible, + this.freeTrialDurationMinutes = 5, + }); + + @override + List get props => [tiers, freeTrialEligible, freeTrialDurationMinutes]; +} + +class PricingError extends ChatOpeningState { + final String message; + PricingError(this.message); + @override + List get props => [message]; +} + +// Model +class PriceTier { + final int durationMinutes; + final int price; + final String label; + + PriceTier({required this.durationMinutes, required this.price, required this.label}); + + factory PriceTier.fromJson(Map json) { + return PriceTier( + durationMinutes: json['duration_minutes'] as int, + price: json['price'] as int, + label: json['label'] as String, + ); + } +} + +// Bloc +class ChatOpeningBloc extends Bloc { + final ApiClient apiClient; + + ChatOpeningBloc({required this.apiClient}) : super(PricingInitial()) { + on(_onLoadPricing); + } + + Future _onLoadPricing(LoadPricing event, Emitter emit) async { + emit(PricingLoading()); + try { + final response = await apiClient.get('/api/client/chat/pricing'); + final data = response['data'] as Map; + final tiersJson = data['tiers'] as List; + final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map)).toList(); + final freeTrial = data['free_trial'] as Map; + + emit(PricingLoaded( + tiers: tiers, + freeTrialEligible: freeTrial['eligible'] as bool? ?? false, + freeTrialDurationMinutes: freeTrial['duration_minutes'] as int? ?? 5, + )); + } catch (e) { + emit(PricingError('Gagal memuat harga. Coba lagi.')); + } + } +} diff --git a/client_app/lib/core/chat/session_closure_bloc.dart b/client_app/lib/core/chat/session_closure_bloc.dart new file mode 100644 index 0000000..d259308 --- /dev/null +++ b/client_app/lib/core/chat/session_closure_bloc.dart @@ -0,0 +1,90 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../api/api_client.dart'; + +// Events +abstract class SessionClosureEvent extends Equatable { + @override + List get props => []; +} + +class RequestExtension extends SessionClosureEvent { + final String sessionId; + final int durationMinutes; + final int price; + RequestExtension({required this.sessionId, required this.durationMinutes, required this.price}); + @override + List get props => [sessionId, durationMinutes, price]; +} + +class DeclineExtension extends SessionClosureEvent {} + +class SubmitGoodbye extends SessionClosureEvent { + final String sessionId; + final String message; + SubmitGoodbye({required this.sessionId, required this.message}); + @override + List get props => [sessionId, message]; +} + +// States +abstract class SessionClosureState extends Equatable { + @override + List get props => []; +} + +class ClosureInitial extends SessionClosureState {} +class ExtendingWaitingMitra extends SessionClosureState {} + +class ClosureShowGoodbye extends SessionClosureState {} + +class ClosureSubmitting extends SessionClosureState {} + +class ClosureComplete extends SessionClosureState {} + +class ClosureError extends SessionClosureState { + final String message; + ClosureError(this.message); + @override + List get props => [message]; +} + +// Bloc +class SessionClosureBloc extends Bloc { + final ApiClient apiClient; + + SessionClosureBloc({required this.apiClient}) : super(ClosureInitial()) { + on(_onRequestExtension); + on(_onDeclineExtension); + on(_onSubmitGoodbye); + } + + Future _onRequestExtension(RequestExtension event, Emitter emit) async { + emit(ExtendingWaitingMitra()); + try { + await apiClient.post('/api/client/chat/session/${event.sessionId}/extend', data: { + 'duration_minutes': event.durationMinutes, + 'price': event.price, + }); + // Response will come via WebSocket (ChatBloc handles it) + } catch (e) { + emit(ClosureError('Gagal meminta perpanjangan.')); + } + } + + void _onDeclineExtension(DeclineExtension event, Emitter emit) { + emit(ClosureShowGoodbye()); + } + + Future _onSubmitGoodbye(SubmitGoodbye event, Emitter emit) async { + emit(ClosureSubmitting()); + try { + await apiClient.post('/api/shared/sessions/${event.sessionId}/close-message', data: { + 'message': event.message, + }); + emit(ClosureComplete()); + } catch (e) { + emit(ClosureError('Gagal mengirim pesan penutup.')); + } + } +} diff --git a/client_app/lib/core/pairing/pairing_bloc.dart b/client_app/lib/core/pairing/pairing_bloc.dart index bf456fe..bc0bad0 100644 --- a/client_app/lib/core/pairing/pairing_bloc.dart +++ b/client_app/lib/core/pairing/pairing_bloc.dart @@ -12,6 +12,16 @@ abstract class PairingEvent extends Equatable { } class RequestPairing extends PairingEvent {} + +class RequestPairingWithTier extends PairingEvent { + final int? durationMinutes; + final int? price; + final bool isFreeTrial; + RequestPairingWithTier({this.durationMinutes, this.price, this.isFreeTrial = false}); + @override + List get props => [durationMinutes, price, isFreeTrial]; +} + class CancelPairing extends PairingEvent {} class _PairingStatusUpdate extends PairingEvent { @@ -71,29 +81,42 @@ class PairingBloc extends Bloc { PairingBloc({required this.apiClient}) : super(PairingInitial()) { on(_onRequestPairing); + on(_onRequestPairingWithTier); on(_onCancelPairing); on<_PairingStatusUpdate>(_onStatusUpdate); on<_PairingTimeout>(_onTimeout); } Future _onRequestPairing(RequestPairing event, Emitter emit) async { - // Reset to initial so BlocListener can detect new errors + await _doPairingRequest(emit, {}); + } + + Future _onRequestPairingWithTier(RequestPairingWithTier event, Emitter emit) async { + final body = {}; + if (event.isFreeTrial) { + body['is_free_trial'] = true; + } else { + body['duration_minutes'] = event.durationMinutes; + body['price'] = event.price; + } + await _doPairingRequest(emit, body); + } + + Future _doPairingRequest(Emitter emit, Map body) async { if (state is! PairingInitial) { emit(PairingInitial()); } try { - final response = await apiClient.post('/api/client/chat/request'); + final response = await apiClient.post('/api/client/chat/request', data: body); 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']; @@ -101,6 +124,8 @@ class PairingBloc extends Bloc { emit(PairingNoBestie()); } else if (code == 'ALREADY_ACTIVE') { emit(PairingError('Kamu sudah memiliki sesi aktif.')); + } else if (code == 'FREE_TRIAL_INELIGIBLE') { + emit(PairingError('Kamu tidak memenuhi syarat untuk free trial.')); } else { emit(PairingError('Gagal memulai. Coba lagi.')); } diff --git a/client_app/lib/features/chat/screens/chat_history_screen.dart b/client_app/lib/features/chat/screens/chat_history_screen.dart new file mode 100644 index 0000000..d55a4c1 --- /dev/null +++ b/client_app/lib/features/chat/screens/chat_history_screen.dart @@ -0,0 +1,71 @@ +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 ChatHistoryScreen extends StatefulWidget { + const ChatHistoryScreen({super.key}); + + @override + State createState() => _ChatHistoryScreenState(); +} + +class _ChatHistoryScreenState extends State { + List> _sessions = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadHistory(); + } + + Future _loadHistory() async { + try { + final api = context.read(); + final response = await api.get('/api/client/chat/history'); + final items = (response['data']['items'] as List).cast>(); + setState(() { + _sessions = items; + _loading = false; + }); + } catch (_) { + setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Riwayat Chat')), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _sessions.isEmpty + ? const Center(child: Text('Belum ada riwayat chat')) + : ListView.builder( + itemCount: _sessions.length, + itemBuilder: (context, index) { + final s = _sessions[index]; + final mitraName = s['mitra_display_name'] as String? ?? 'Bestie'; + final endedAt = s['ended_at'] != null + ? DateTime.parse(s['ended_at'] as String).toLocal() + : null; + final duration = s['duration_minutes'] as int?; + final closureMsg = s['customer_closure_message'] as String?; + + return ListTile( + leading: const CircleAvatar(child: Icon(Icons.person)), + title: Text(mitraName), + subtitle: Text([ + if (endedAt != null) '${endedAt.day}/${endedAt.month}/${endedAt.year}', + if (duration != null) '$duration menit', + if (closureMsg != null) '"$closureMsg"', + ].join(' - ')), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/chat/history/${s['id']}'), + ); + }, + ), + ); + } +} diff --git a/client_app/lib/features/chat/screens/chat_screen.dart b/client_app/lib/features/chat/screens/chat_screen.dart new file mode 100644 index 0000000..ca62776 --- /dev/null +++ b/client_app/lib/features/chat/screens/chat_screen.dart @@ -0,0 +1,352 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/chat/chat_bloc.dart'; +import '../../../core/chat/session_closure_bloc.dart'; +import '../widgets/pricing_bottom_sheet.dart'; + +class ChatScreen extends StatefulWidget { + final String sessionId; + final String mitraName; + + const ChatScreen({super.key, required this.sessionId, required this.mitraName}); + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + final _messageController = TextEditingController(); + final _scrollController = ScrollController(); + Timer? _typingThrottle; + + @override + void dispose() { + _messageController.dispose(); + _scrollController.dispose(); + _typingThrottle?.cancel(); + super.dispose(); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + }); + } + + void _onTextChanged(String text) { + if (_typingThrottle?.isActive ?? false) return; + context.read().add(SendTyping()); + _typingThrottle = Timer(const Duration(seconds: 2), () {}); + } + + void _sendMessage() { + final text = _messageController.text.trim(); + if (text.isEmpty) return; + context.read().add(SendMessage(text)); + _messageController.clear(); + _scrollToBottom(); + } + + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (prev, curr) { + if (prev is ChatConnected && curr is ChatConnected) { + return prev.sessionExpired != curr.sessionExpired || + prev.sessionClosing != curr.sessionClosing || + prev.messages.length != curr.messages.length; + } + return true; + }, + listener: (context, state) { + if (state is ChatConnected) { + if (state.sessionClosing) { + context.read().add(DeclineExtension()); + } + _scrollToBottom(); + // Auto-mark received messages as read + final unread = state.messages + .where((m) => m.senderType == 'mitra' && m.status != 'read') + .map((m) => m.id) + .toList(); + if (unread.isNotEmpty) { + context.read().add(MarkMessagesRead(unread)); + } + } + }, + ), + BlocListener( + listener: (context, state) { + if (state is ClosureComplete) { + context.go('/home'); + } + }, + ), + ], + child: Scaffold( + appBar: AppBar( + title: Text(widget.mitraName), + automaticallyImplyLeading: false, + actions: [ + BlocBuilder( + builder: (context, state) { + if (state is ChatConnected && state.remainingSeconds != null) { + return Padding( + padding: const EdgeInsets.only(right: 16), + child: Center( + child: Text( + '${state.remainingSeconds}s', + style: TextStyle( + color: state.remainingSeconds! < 30 ? Colors.red : null, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + if (state is ChatConnecting) { + return const Center(child: CircularProgressIndicator()); + } + if (state is ChatError) { + return Center(child: Text(state.message)); + } + if (state is ChatConnected) { + return _buildChatBody(context, state); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } + + Widget _buildChatBody(BuildContext context, ChatConnected state) { + // Show session expired dialog + if (state.sessionExpired) { + return _buildExpiredView(context); + } + + // Show goodbye input + final closureState = context.watch().state; + if (closureState is ClosureShowGoodbye || closureState is ClosureSubmitting) { + return _buildGoodbyeView(context, closureState); + } + + if (state.sessionPaused) { + return _buildPausedView(); + } + + return Column( + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: state.messages.length, + itemBuilder: (context, index) { + final msg = state.messages[index]; + final isMe = msg.senderType == 'customer'; + return _buildMessageBubble(msg, isMe); + }, + ), + ), + if (state.isOtherTyping) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text('Bestie sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)), + ), + ), + _buildInputBar(context, state), + ], + ); + } + + Widget _buildMessageBubble(ChatMessage msg, bool isMe) { + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75), + decoration: BoxDecoration( + color: isMe ? Colors.blue.shade100 : Colors.grey.shade200, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(msg.content, style: const TextStyle(fontSize: 15)), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${msg.createdAt.hour.toString().padLeft(2, '0')}:${msg.createdAt.minute.toString().padLeft(2, '0')}', + style: const TextStyle(fontSize: 10, color: Colors.grey), + ), + if (isMe) ...[ + const SizedBox(width: 4), + _buildStatusIcon(msg.status), + ], + ], + ), + ], + ), + ), + ); + } + + Widget _buildStatusIcon(String status) { + switch (status) { + case 'sending': + return const Icon(Icons.access_time, size: 14, color: Colors.grey); + case 'sent': + return const Icon(Icons.check, size: 14, color: Colors.grey); + case 'delivered': + return const Icon(Icons.done_all, size: 14, color: Colors.grey); + case 'read': + return const Icon(Icons.done_all, size: 14, color: Colors.blue); + default: + return const SizedBox.shrink(); + } + } + + Widget _buildInputBar(BuildContext context, ChatConnected state) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + onChanged: _onTextChanged, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendMessage(), + decoration: InputDecoration( + hintText: 'Ketik pesan...', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.send, color: Colors.blue), + onPressed: _sendMessage, + ), + ], + ), + ), + ); + } + + Widget _buildExpiredView(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.timer_off, size: 64, color: Colors.orange), + const SizedBox(height: 16), + const Text('Waktu sesi habis', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('Apakah kamu ingin memperpanjang sesi?', textAlign: TextAlign.center), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => PricingBottomSheet.show(context), + child: const Text('Perpanjang Sesi'), + ), + const SizedBox(height: 12), + TextButton( + onPressed: () => context.read().add(DeclineExtension()), + child: const Text('Tidak, akhiri sesi'), + ), + ], + ), + ), + ); + } + + Widget _buildGoodbyeView(BuildContext context, SessionClosureState closureState) { + final controller = TextEditingController(); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.waving_hand, size: 64, color: Colors.amber), + const SizedBox(height: 16), + const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('Tuliskan pesan terakhirmu untuk Bestie', textAlign: TextAlign.center), + const SizedBox(height: 24), + TextField( + controller: controller, + maxLines: 3, + decoration: InputDecoration( + hintText: 'Terima kasih, Bestie...', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: closureState is ClosureSubmitting + ? null + : () { + final text = controller.text.trim(); + if (text.isNotEmpty) { + context.read().add( + SubmitGoodbye(sessionId: widget.sessionId, message: text), + ); + } + }, + child: closureState is ClosureSubmitting + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Kirim & Selesai'), + ), + ], + ), + ), + ); + } + + Widget _buildPausedView() { + return const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 24), + Text('Menunggu konfirmasi Bestie...', style: TextStyle(fontSize: 18)), + SizedBox(height: 8), + Text('Chat dijeda sementara', style: TextStyle(color: Colors.grey)), + ], + ), + ), + ); + } +} diff --git a/client_app/lib/features/chat/screens/chat_transcript_screen.dart b/client_app/lib/features/chat/screens/chat_transcript_screen.dart new file mode 100644 index 0000000..9cc380d --- /dev/null +++ b/client_app/lib/features/chat/screens/chat_transcript_screen.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../core/api/api_client.dart'; + +class ChatTranscriptScreen extends StatefulWidget { + final String sessionId; + + const ChatTranscriptScreen({super.key, required this.sessionId}); + + @override + State createState() => _ChatTranscriptScreenState(); +} + +class _ChatTranscriptScreenState extends State { + List> _messages = []; + List> _closures = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadTranscript(); + } + + Future _loadTranscript() async { + try { + final api = context.read(); + final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript'); + final data = response['data'] as Map; + setState(() { + _messages = (data['messages'] as List).cast>(); + _closures = (data['closures'] as List).cast>(); + _loading = false; + }); + } catch (_) { + setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Transkrip Chat')), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + ..._messages.map((m) { + final isMe = m['sender_type'] == 'customer'; + final time = DateTime.parse(m['created_at'] as String).toLocal(); + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75), + decoration: BoxDecoration( + color: isMe ? Colors.blue.shade100 : Colors.grey.shade200, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(m['content'] as String, style: const TextStyle(fontSize: 15)), + const SizedBox(height: 4), + Text( + '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}', + style: const TextStyle(fontSize: 10, color: Colors.grey), + ), + ], + ), + ), + ); + }), + if (_closures.isNotEmpty) ...[ + const Divider(height: 32), + const Text('Pesan Penutup', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 8), + ..._closures.map((c) => Card( + child: ListTile( + title: Text(c['user_type'] == 'customer' ? 'Kamu' : 'Bestie'), + subtitle: Text(c['message'] as String), + ), + )), + ], + ], + ), + ); + } +} diff --git a/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart new file mode 100644 index 0000000..952440c --- /dev/null +++ b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../core/api/api_client.dart'; +import '../../../core/chat/chat_opening_bloc.dart'; +import '../../../core/pairing/pairing_bloc.dart'; + +class PricingBottomSheet extends StatelessWidget { + const PricingBottomSheet({super.key}); + + static Future show(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => BlocProvider( + create: (ctx) => ChatOpeningBloc(apiClient: ctx.read())..add(LoadPricing()), + child: const PricingBottomSheet(), + ), + ); + } + + String _formatPrice(int price) { + final str = price.toString(); + final buffer = StringBuffer(); + for (var i = 0; i < str.length; i++) { + if (i > 0 && (str.length - i) % 3 == 0) buffer.write('.'); + buffer.write(str[i]); + } + return 'Rp $buffer'; + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is PricingLoading || state is PricingInitial) { + return const SizedBox( + height: 200, + child: Center(child: CircularProgressIndicator()), + ); + } + + if (state is PricingError) { + return SizedBox( + height: 200, + child: Center(child: Text(state.message)), + ); + } + + if (state is PricingLoaded) { + return DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.8, + expand: false, + builder: (_, scrollController) { + return Padding( + padding: const EdgeInsets.all(24), + child: ListView( + controller: scrollController, + children: [ + const Text( + 'Pilih Durasi Curhat', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + if (state.freeTrialEligible) ...[ + Card( + color: Colors.green.shade50, + child: ListTile( + leading: const Icon(Icons.card_giftcard, color: Colors.green), + title: Text('Free Trial (${state.freeTrialDurationMinutes} Menit)'), + subtitle: const Text('Gratis untuk pertama kali!'), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () { + Navigator.of(context).pop(); + _startPairing(context, isFreeTrial: true); + }, + ), + ), + const Divider(height: 24), + ], + ...state.tiers.map((tier) => Card( + child: ListTile( + title: Text(tier.label), + trailing: Text( + _formatPrice(tier.price), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + onTap: () { + Navigator.of(context).pop(); + _startPairing( + context, + durationMinutes: tier.durationMinutes, + price: tier.price, + ); + }, + ), + )), + ], + ), + ); + }, + ); + } + + return const SizedBox.shrink(); + }, + ); + } + + void _startPairing(BuildContext context, {bool isFreeTrial = false, int? durationMinutes, int? price}) { + context.read().add(RequestPairingWithTier( + durationMinutes: durationMinutes, + price: price, + isFreeTrial: isFreeTrial, + )); + } +} diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart index 74e731d..37d2268 100644 --- a/client_app/lib/features/home/home_screen.dart +++ b/client_app/lib/features/home/home_screen.dart @@ -3,6 +3,7 @@ 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'; +import '../chat/widgets/pricing_bottom_sheet.dart'; class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @@ -33,6 +34,10 @@ class HomeScreen extends StatelessWidget { appBar: AppBar( title: const Text('Halo Bestie'), actions: [ + IconButton( + icon: const Icon(Icons.history), + onPressed: () => context.push('/chat/history'), + ), IconButton( icon: const Icon(Icons.logout), onPressed: () => context.read().add(LogoutRequested()), @@ -51,7 +56,7 @@ class HomeScreen extends StatelessWidget { style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16), ), - onPressed: () => context.read().add(RequestPairing()), + onPressed: () => PricingBottomSheet.show(context), child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)), ), ], diff --git a/client_app/lib/main.dart b/client_app/lib/main.dart index f4b3d9b..2042372 100644 --- a/client_app/lib/main.dart +++ b/client_app/lib/main.dart @@ -1,9 +1,12 @@ import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; 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'; import 'core/auth/auth_bloc.dart'; +import 'core/chat/chat_bloc.dart'; +import 'core/chat/session_closure_bloc.dart'; import 'core/pairing/pairing_bloc.dart'; import 'firebase_options.dart'; import 'router.dart'; @@ -11,6 +14,11 @@ import 'router.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + + // Request notification permission + final messaging = FirebaseMessaging.instance; + await messaging.requestPermission(); + runApp(const App()); } @@ -31,6 +39,21 @@ class _AppState extends State { super.initState(); _authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted()); _router = buildRouter(_authBloc); + _registerFcmToken(); + } + + Future _registerFcmToken() async { + // Listen for auth state, then register token + _authBloc.stream.listen((state) async { + if (state is AuthAuthenticated || state is AuthAnonymous) { + try { + final token = await FirebaseMessaging.instance.getToken(); + if (token != null) { + await _apiClient.post('/api/shared/device-token', data: {'token': token}); + } + } catch (_) {} + } + }); } @override @@ -46,6 +69,8 @@ class _AppState extends State { providers: [ BlocProvider.value(value: _authBloc), BlocProvider(create: (_) => PairingBloc(apiClient: _apiClient)), + BlocProvider(create: (_) => ChatBloc(apiClient: _apiClient)), + BlocProvider(create: (_) => SessionClosureBloc(apiClient: _apiClient)), RepositoryProvider.value(value: _apiClient), ], child: MaterialApp.router( diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index 112bc24..f9234d3 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -11,7 +11,9 @@ 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'; +import 'features/chat/screens/chat_screen.dart'; +import 'features/chat/screens/chat_history_screen.dart'; +import 'features/chat/screens/chat_transcript_screen.dart'; /// Converts a BLoC stream into a ChangeNotifier for GoRouter's refreshListenable. class _BlocRefreshNotifier extends ChangeNotifier { @@ -64,11 +66,16 @@ GoRouter buildRouter(AuthBloc authBloc) { }), GoRoute(path: '/chat/no-bestie', builder: (_, __) => const NoBestieScreen()), GoRoute(path: '/chat/session/:sessionId', builder: (context, state) { - return SessionActiveScreen( + final extra = state.extra as Map?; + return ChatScreen( sessionId: state.pathParameters['sessionId']!, - mitraName: state.extra as String? ?? 'Bestie', + mitraName: extra?['mitraName'] as String? ?? 'Bestie', ); }), + GoRoute(path: '/chat/history', builder: (_, __) => const ChatHistoryScreen()), + GoRoute(path: '/chat/history/:sessionId', builder: (context, state) { + return ChatTranscriptScreen(sessionId: state.pathParameters['sessionId']!); + }), ], ); } diff --git a/client_app/macos/Flutter/GeneratedPluginRegistrant.swift b/client_app/macos/Flutter/GeneratedPluginRegistrant.swift index a8948b8..358099a 100644 --- a/client_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import firebase_auth import firebase_core +import firebase_messaging import google_sign_in_ios import shared_preferences_foundation import sign_in_with_apple @@ -14,6 +15,7 @@ import sign_in_with_apple func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) diff --git a/client_app/pubspec.lock b/client_app/pubspec.lock index acef217..5c3ce14 100644 --- a/client_app/pubspec.lock +++ b/client_app/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" dio: dependency: "direct main" description: @@ -153,6 +161,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.17.5" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: a1662cc95d9750a324ad9df349b873360af6f11414902021f130c68ec02267c4 + url: "https://pub.dev" + source: hosted + version: "14.9.4" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "87c4a922cb6f811cfb7a889bdbb3622702443c52a0271636cbc90d813ceac147" + url: "https://pub.dev" + source: hosted + version: "4.5.37" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "0d34dca01a7b103ed7f20138bffbb28eb0e61a677bf9e78a028a932e2c7322d5" + url: "https://pub.dev" + source: hosted + version: "3.8.7" flutter: dependency: "direct main" description: flutter @@ -557,6 +589,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" + url: "https://pub.dev" + source: hosted + version: "2.4.5" xdg_directories: dependency: transitive description: diff --git a/client_app/pubspec.yaml b/client_app/pubspec.yaml index c2d18dc..7547c44 100644 --- a/client_app/pubspec.yaml +++ b/client_app/pubspec.yaml @@ -14,13 +14,15 @@ dependencies: # Firebase firebase_core: ^2.27.1 firebase_auth: ^4.18.0 + firebase_messaging: ^14.7.15 # Social login google_sign_in: ^6.2.1 sign_in_with_apple: ^6.1.0 - # HTTP + # HTTP & WebSocket dio: ^5.4.3 + web_socket_channel: ^2.4.5 # State management flutter_bloc: ^8.1.5 diff --git a/control_center/src/pages/settings/SettingsPage.jsx b/control_center/src/pages/settings/SettingsPage.jsx index 9752ffa..07ced32 100644 --- a/control_center/src/pages/settings/SettingsPage.jsx +++ b/control_center/src/pages/settings/SettingsPage.jsx @@ -21,6 +21,37 @@ const updateMaxCustomersConfig = async (max_customers_per_mitra) => { return res.data.data } +// Phase 3 config fetchers +const fetchFreeTrialConfig = async () => { + const res = await apiClient.get('/internal/config/free-trial') + return res.data.data +} + +const updateFreeTrialConfig = async (data) => { + const res = await apiClient.patch('/internal/config/free-trial', data) + return res.data.data +} + +const fetchExtensionTimeoutConfig = async () => { + const res = await apiClient.get('/internal/config/extension-timeout') + return res.data.data +} + +const updateExtensionTimeoutConfig = async (extension_timeout_seconds) => { + const res = await apiClient.patch('/internal/config/extension-timeout', { extension_timeout_seconds }) + return res.data.data +} + +const fetchEarlyEndConfig = async () => { + const res = await apiClient.get('/internal/config/early-end') + return res.data.data +} + +const updateEarlyEndConfig = async (data) => { + const res = await apiClient.patch('/internal/config/early-end', data) + return res.data.data +} + export default function SettingsPage() { const queryClient = useQueryClient() const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig }) @@ -40,7 +71,37 @@ export default function SettingsPage() { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-max-customers'] }), }) - if (isLoading || maxLoading) return
Loading...
+ // Phase 3: Free Trial + const { data: ftData, isLoading: ftLoading } = useQuery({ + queryKey: ['config-free-trial'], + queryFn: fetchFreeTrialConfig, + }) + const ftMutation = useMutation({ + mutationFn: updateFreeTrialConfig, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-free-trial'] }), + }) + + // Phase 3: Extension Timeout + const { data: etData, isLoading: etLoading } = useQuery({ + queryKey: ['config-extension-timeout'], + queryFn: fetchExtensionTimeoutConfig, + }) + const etMutation = useMutation({ + mutationFn: updateExtensionTimeoutConfig, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-extension-timeout'] }), + }) + + // Phase 3: Early End + const { data: eeData, isLoading: eeLoading } = useQuery({ + queryKey: ['config-early-end'], + queryFn: fetchEarlyEndConfig, + }) + const eeMutation = useMutation({ + mutationFn: updateEarlyEndConfig, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-early-end'] }), + }) + + if (isLoading || maxLoading || ftLoading || etLoading || eeLoading) return
Loading...
return (
@@ -80,6 +141,80 @@ export default function SettingsPage() {
{maxMutation.isError &&

Gagal menyimpan.

} + +
+

Free Trial

+

Aktifkan free trial untuk customer baru yang belum pernah bertransaksi.

+ +
+ + { + const val = parseInt(e.target.value, 10) + if (val >= 1) ftMutation.mutate({ duration_minutes: val }) + }} + disabled={ftMutation.isPending} + style={{ width: 80 }} + /> + menit +
+ {ftMutation.isError &&

Gagal menyimpan.

} +
+ +
+

Extension Timeout

+

Waktu tunggu untuk customer memutuskan dan mitra mengkonfirmasi perpanjangan sesi.

+
+ { + const val = parseInt(e.target.value, 10) + if (val >= 10) etMutation.mutate(val) + }} + disabled={etMutation.isPending} + style={{ width: 80 }} + /> + detik +
+ {etMutation.isError &&

Gagal menyimpan.

} +
+ +
+

Akhiri Sesi Lebih Awal

+

Izinkan mitra dan/atau customer untuk mengakhiri sesi sebelum waktu habis.

+ + + {eeMutation.isError &&

Gagal menyimpan.

} +
) } diff --git a/mitra_app/lib/core/chat/extension_bloc.dart b/mitra_app/lib/core/chat/extension_bloc.dart new file mode 100644 index 0000000..ec53435 --- /dev/null +++ b/mitra_app/lib/core/chat/extension_bloc.dart @@ -0,0 +1,84 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../api/api_client.dart'; + +// Events +abstract class ExtensionEvent extends Equatable { + @override + List get props => []; +} + +class RespondToExtension extends ExtensionEvent { + final String sessionId; + final String extensionId; + final bool accepted; + RespondToExtension({required this.sessionId, required this.extensionId, required this.accepted}); + @override + List get props => [sessionId, extensionId, accepted]; +} + +class SubmitGoodbye extends ExtensionEvent { + final String sessionId; + final String message; + SubmitGoodbye({required this.sessionId, required this.message}); + @override + List get props => [sessionId, message]; +} + +// States +abstract class ExtensionState extends Equatable { + @override + List get props => []; +} + +class ExtensionIdle extends ExtensionState {} +class ExtensionResponding extends ExtensionState {} +class ExtensionShowGoodbye extends ExtensionState {} +class ExtensionSubmitting extends ExtensionState {} +class ExtensionComplete extends ExtensionState {} + +class ExtensionError extends ExtensionState { + final String message; + ExtensionError(this.message); + @override + List get props => [message]; +} + +// Bloc +class ExtensionBloc extends Bloc { + final ApiClient apiClient; + + ExtensionBloc({required this.apiClient}) : super(ExtensionIdle()) { + on(_onRespond); + on(_onSubmitGoodbye); + } + + Future _onRespond(RespondToExtension event, Emitter emit) async { + emit(ExtensionResponding()); + try { + await apiClient.post('/api/mitra/chat-requests/sessions/${event.sessionId}/extend-response', data: { + 'extension_id': event.extensionId, + 'accepted': event.accepted, + }); + if (!event.accepted) { + emit(ExtensionShowGoodbye()); + } else { + emit(ExtensionIdle()); + } + } catch (e) { + emit(ExtensionError('Gagal merespon perpanjangan.')); + } + } + + Future _onSubmitGoodbye(SubmitGoodbye event, Emitter emit) async { + emit(ExtensionSubmitting()); + try { + await apiClient.post('/api/shared/sessions/${event.sessionId}/close-message', data: { + 'message': event.message, + }); + emit(ExtensionComplete()); + } catch (e) { + emit(ExtensionError('Gagal mengirim pesan penutup.')); + } + } +} diff --git a/mitra_app/lib/core/chat/mitra_chat_bloc.dart b/mitra_app/lib/core/chat/mitra_chat_bloc.dart new file mode 100644 index 0000000..89cd69e --- /dev/null +++ b/mitra_app/lib/core/chat/mitra_chat_bloc.dart @@ -0,0 +1,351 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:equatable/equatable.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import '../api/api_client.dart'; + +// Events +abstract class MitraChatEvent extends Equatable { + @override + List get props => []; +} + +class ConnectChat extends MitraChatEvent { + final String sessionId; + ConnectChat(this.sessionId); + @override + List get props => [sessionId]; +} + +class DisconnectChat extends MitraChatEvent {} + +class SendMessage extends MitraChatEvent { + final String content; + SendMessage(this.content); + @override + List get props => [content]; +} + +class SendTyping extends MitraChatEvent {} + +class _MessageReceived extends MitraChatEvent { + final Map data; + _MessageReceived(this.data); + @override + List get props => [data]; +} + +class _ConnectionError extends MitraChatEvent {} + +class MarkMessagesDelivered extends MitraChatEvent { + final List messageIds; + MarkMessagesDelivered(this.messageIds); + @override + List get props => [messageIds]; +} + +class MarkMessagesRead extends MitraChatEvent { + final List messageIds; + MarkMessagesRead(this.messageIds); + @override + List get props => [messageIds]; +} + +// States +abstract class MitraChatState extends Equatable { + @override + List get props => []; +} + +class ChatInitial extends MitraChatState {} +class ChatConnecting extends MitraChatState {} + +class ChatConnected extends MitraChatState { + final List messages; + final bool isOtherTyping; + final int? remainingSeconds; + final bool sessionExpired; + final bool sessionClosing; + final Map? extensionRequest; + + ChatConnected({ + required this.messages, + this.isOtherTyping = false, + this.remainingSeconds, + this.sessionExpired = false, + this.sessionClosing = false, + this.extensionRequest, + }); + + ChatConnected copyWith({ + List? messages, + bool? isOtherTyping, + int? remainingSeconds, + bool? sessionExpired, + bool? sessionClosing, + Map? extensionRequest, + }) { + return ChatConnected( + messages: messages ?? this.messages, + isOtherTyping: isOtherTyping ?? this.isOtherTyping, + remainingSeconds: remainingSeconds ?? this.remainingSeconds, + sessionExpired: sessionExpired ?? this.sessionExpired, + sessionClosing: sessionClosing ?? this.sessionClosing, + extensionRequest: extensionRequest ?? this.extensionRequest, + ); + } + + @override + List get props => [messages, isOtherTyping, remainingSeconds, sessionExpired, sessionClosing, extensionRequest]; +} + +class ChatError extends MitraChatState { + final String message; + ChatError(this.message); + @override + List get props => [message]; +} + +// Message model +class ChatMessage { + final String id; + final String senderType; + final String content; + final String type; + final String status; + final DateTime createdAt; + + ChatMessage({ + required this.id, + required this.senderType, + required this.content, + this.type = 'text', + this.status = 'sent', + required this.createdAt, + }); + + ChatMessage copyWith({String? status}) { + return ChatMessage( + id: id, + senderType: senderType, + content: content, + type: type, + status: status ?? this.status, + createdAt: createdAt, + ); + } +} + +// Bloc +class MitraChatBloc extends Bloc { + final ApiClient apiClient; + WebSocketChannel? _channel; + StreamSubscription? _wsSubscription; + Timer? _typingTimer; + + MitraChatBloc({required this.apiClient}) : super(ChatInitial()) { + on(_onConnect); + on(_onDisconnect); + on(_onSendMessage); + on(_onSendTyping); + on<_MessageReceived>(_onMessageReceived); + on<_ConnectionError>(_onConnectionError); + on(_onMarkDelivered); + on(_onMarkRead); + } + + Future _onConnect(ConnectChat event, Emitter emit) async { + emit(ChatConnecting()); + + try { + final response = await apiClient.get('/api/shared/chat/${event.sessionId}/messages'); + final messagesData = response['data'] as List; + final messages = messagesData.map((m) => ChatMessage( + id: m['id'] as String, + senderType: m['sender_type'] as String, + content: m['content'] as String, + type: m['type'] as String? ?? 'text', + status: m['status'] as String? ?? 'sent', + createdAt: DateTime.parse(m['created_at'] as String), + )).toList(); + + final user = FirebaseAuth.instance.currentUser; + final token = await user?.getIdToken(); + final wsUrl = ApiClient.baseUrl + .replaceFirst('https://', 'wss://') + .replaceFirst('http://', 'ws://'); + _channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws')); + + _wsSubscription = _channel!.stream.listen( + (raw) { + final data = jsonDecode(raw as String) as Map; + add(_MessageReceived(data)); + }, + onError: (_) => add(_ConnectionError()), + onDone: () => add(_ConnectionError()), + ); + + _channel!.sink.add(jsonEncode({ + 'type': 'auth', + 'token': token, + 'session_id': event.sessionId, + })); + + emit(ChatConnected(messages: messages)); + } catch (e) { + emit(ChatError('Gagal terhubung ke chat.')); + } + } + + void _onDisconnect(DisconnectChat event, Emitter emit) { + _cleanup(); + emit(ChatInitial()); + } + + void _onSendMessage(SendMessage event, Emitter emit) { + if (state is! ChatConnected || _channel == null) return; + final current = state as ChatConnected; + + final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; + final msg = ChatMessage( + id: tempId, + senderType: 'mitra', + content: event.content, + status: 'sending', + createdAt: DateTime.now(), + ); + + emit(current.copyWith(messages: [...current.messages, msg])); + + _channel!.sink.add(jsonEncode({ + 'type': 'message', + 'content': event.content, + '_temp_id': tempId, + })); + } + + void _onSendTyping(SendTyping event, Emitter emit) { + if (_channel == null) return; + _channel!.sink.add(jsonEncode({'type': 'typing'})); + } + + void _onMarkDelivered(MarkMessagesDelivered event, Emitter emit) { + if (_channel == null) return; + _channel!.sink.add(jsonEncode({'type': 'delivered', 'message_ids': event.messageIds})); + } + + void _onMarkRead(MarkMessagesRead event, Emitter emit) { + if (_channel == null) return; + _channel!.sink.add(jsonEncode({'type': 'read', 'message_ids': event.messageIds})); + } + + void _onMessageReceived(_MessageReceived event, Emitter emit) { + if (state is! ChatConnected) return; + final current = state as ChatConnected; + final data = event.data; + final type = data['type'] as String?; + + switch (type) { + case 'auth_ok': + break; + + case 'message': + final msg = ChatMessage( + id: data['message_id'] as String, + senderType: data['sender_type'] as String, + content: data['content'] as String, + type: data['message_type'] as String? ?? 'text', + status: 'sent', + createdAt: DateTime.parse(data['created_at'] as String), + ); + emit(current.copyWith(messages: [...current.messages, msg])); + add(MarkMessagesDelivered([msg.id])); + break; + + case 'message_ack': + final messageId = data['message_id'] as String; + final status = data['status'] as String; + final updatedMessages = current.messages.map((m) { + if (m.status == 'sending') return m.copyWith(status: status); + return m; + }).toList(); + final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == 'mitra'); + if (idx >= 0) { + final old = updatedMessages[idx]; + updatedMessages[idx] = ChatMessage( + id: messageId, + senderType: old.senderType, + content: old.content, + type: old.type, + status: status, + createdAt: old.createdAt, + ); + } + emit(current.copyWith(messages: updatedMessages)); + break; + + case 'message_status': + final messageIds = (data['message_ids'] as List).cast(); + final status = data['status'] as String; + final updatedMessages = current.messages.map((m) { + if (messageIds.contains(m.id)) return m.copyWith(status: status); + return m; + }).toList(); + emit(current.copyWith(messages: updatedMessages)); + break; + + case 'typing': + emit(current.copyWith(isOtherTyping: true)); + _typingTimer?.cancel(); + _typingTimer = Timer(const Duration(seconds: 3), () { + if (state is ChatConnected) { + emit((state as ChatConnected).copyWith(isOtherTyping: false)); + } + }); + break; + + case 'session_timer': + emit(current.copyWith(remainingSeconds: data['remaining_seconds'] as int?)); + break; + + case 'session_expired': + emit(current.copyWith(sessionExpired: true)); + break; + + case 'extension_request': + emit(current.copyWith(extensionRequest: data)); + break; + + case 'session_resumed': + emit(current.copyWith(sessionExpired: false, extensionRequest: null)); + break; + + case 'session_closing': + emit(current.copyWith(sessionClosing: true)); + break; + + case 'session_completed': + _cleanup(); + break; + } + } + + void _onConnectionError(_ConnectionError event, Emitter emit) {} + + void _cleanup() { + _wsSubscription?.cancel(); + _wsSubscription = null; + _channel?.sink.close(); + _channel = null; + _typingTimer?.cancel(); + _typingTimer = null; + } + + @override + Future close() { + _cleanup(); + return super.close(); + } +} diff --git a/mitra_app/lib/features/chat/screens/chat_history_screen.dart b/mitra_app/lib/features/chat/screens/chat_history_screen.dart new file mode 100644 index 0000000..bf3ad01 --- /dev/null +++ b/mitra_app/lib/features/chat/screens/chat_history_screen.dart @@ -0,0 +1,71 @@ +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 MitraChatHistoryScreen extends StatefulWidget { + const MitraChatHistoryScreen({super.key}); + + @override + State createState() => _MitraChatHistoryScreenState(); +} + +class _MitraChatHistoryScreenState extends State { + List> _sessions = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadHistory(); + } + + Future _loadHistory() async { + try { + final api = context.read(); + final response = await api.get('/api/mitra/chat-requests/history'); + final items = (response['data']['items'] as List).cast>(); + setState(() { + _sessions = items; + _loading = false; + }); + } catch (_) { + setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Riwayat Chat')), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _sessions.isEmpty + ? const Center(child: Text('Belum ada riwayat chat')) + : ListView.builder( + itemCount: _sessions.length, + itemBuilder: (context, index) { + final s = _sessions[index]; + final customerName = s['customer_display_name'] as String? ?? 'Customer'; + final endedAt = s['ended_at'] != null + ? DateTime.parse(s['ended_at'] as String).toLocal() + : null; + final duration = s['duration_minutes'] as int?; + final closureMsg = s['mitra_closure_message'] as String?; + + return ListTile( + leading: const CircleAvatar(child: Icon(Icons.person)), + title: Text(customerName), + subtitle: Text([ + if (endedAt != null) '${endedAt.day}/${endedAt.month}/${endedAt.year}', + if (duration != null) '$duration menit', + if (closureMsg != null) '"$closureMsg"', + ].join(' - ')), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/chat/history/${s['id']}'), + ); + }, + ), + ); + } +} diff --git a/mitra_app/lib/features/chat/screens/chat_transcript_screen.dart b/mitra_app/lib/features/chat/screens/chat_transcript_screen.dart new file mode 100644 index 0000000..1bca658 --- /dev/null +++ b/mitra_app/lib/features/chat/screens/chat_transcript_screen.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../core/api/api_client.dart'; + +class MitraChatTranscriptScreen extends StatefulWidget { + final String sessionId; + + const MitraChatTranscriptScreen({super.key, required this.sessionId}); + + @override + State createState() => _MitraChatTranscriptScreenState(); +} + +class _MitraChatTranscriptScreenState extends State { + List> _messages = []; + List> _closures = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadTranscript(); + } + + Future _loadTranscript() async { + try { + final api = context.read(); + final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript'); + final data = response['data'] as Map; + setState(() { + _messages = (data['messages'] as List).cast>(); + _closures = (data['closures'] as List).cast>(); + _loading = false; + }); + } catch (_) { + setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Transkrip Chat')), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + ..._messages.map((m) { + final isMe = m['sender_type'] == 'mitra'; + final time = DateTime.parse(m['created_at'] as String).toLocal(); + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75), + decoration: BoxDecoration( + color: isMe ? Colors.green.shade100 : Colors.grey.shade200, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(m['content'] as String, style: const TextStyle(fontSize: 15)), + const SizedBox(height: 4), + Text( + '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}', + style: const TextStyle(fontSize: 10, color: Colors.grey), + ), + ], + ), + ), + ); + }), + if (_closures.isNotEmpty) ...[ + const Divider(height: 32), + const Text('Pesan Penutup', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 8), + ..._closures.map((c) => Card( + child: ListTile( + title: Text(c['user_type'] == 'mitra' ? 'Kamu' : 'Customer'), + subtitle: Text(c['message'] as String), + ), + )), + ], + ], + ), + ); + } +} diff --git a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart new file mode 100644 index 0000000..cc7718e --- /dev/null +++ b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart @@ -0,0 +1,337 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/chat/mitra_chat_bloc.dart'; +import '../../../core/chat/extension_bloc.dart'; + +class MitraChatScreen extends StatefulWidget { + final String sessionId; + final String customerName; + + const MitraChatScreen({super.key, required this.sessionId, required this.customerName}); + + @override + State createState() => _MitraChatScreenState(); +} + +class _MitraChatScreenState extends State { + final _messageController = TextEditingController(); + final _scrollController = ScrollController(); + Timer? _typingThrottle; + + @override + void dispose() { + _messageController.dispose(); + _scrollController.dispose(); + _typingThrottle?.cancel(); + super.dispose(); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + }); + } + + void _onTextChanged(String text) { + if (_typingThrottle?.isActive ?? false) return; + context.read().add(SendTyping()); + _typingThrottle = Timer(const Duration(seconds: 2), () {}); + } + + void _sendMessage() { + final text = _messageController.text.trim(); + if (text.isEmpty) return; + context.read().add(SendMessage(text)); + _messageController.clear(); + _scrollToBottom(); + } + + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + if (state is ChatConnected) { + _scrollToBottom(); + final unread = state.messages + .where((m) => m.senderType == 'customer' && m.status != 'read') + .map((m) => m.id) + .toList(); + if (unread.isNotEmpty) { + context.read().add(MarkMessagesRead(unread)); + } + if (state.sessionClosing) { + // Trigger goodbye view + } + } + }, + ), + BlocListener( + listener: (context, state) { + if (state is ExtensionComplete) { + context.go('/home'); + } + }, + ), + ], + child: Scaffold( + appBar: AppBar( + title: Text(widget.customerName), + actions: [ + BlocBuilder( + builder: (context, state) { + if (state is ChatConnected && state.remainingSeconds != null) { + return Padding( + padding: const EdgeInsets.only(right: 16), + child: Center( + child: Text( + '${state.remainingSeconds}s', + style: TextStyle( + color: state.remainingSeconds! < 30 ? Colors.red : null, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + if (state is ChatConnecting) { + return const Center(child: CircularProgressIndicator()); + } + if (state is ChatError) { + return Center(child: Text(state.message)); + } + if (state is ChatConnected) { + return _buildChatBody(context, state); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } + + Widget _buildChatBody(BuildContext context, ChatConnected state) { + // Extension request from customer + if (state.extensionRequest != null) { + return _buildExtensionView(context, state.extensionRequest!); + } + + // Goodbye view + final extState = context.watch().state; + if (state.sessionClosing || extState is ExtensionShowGoodbye || extState is ExtensionSubmitting) { + return _buildGoodbyeView(context, extState); + } + + return Column( + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: state.messages.length, + itemBuilder: (context, index) { + final msg = state.messages[index]; + final isMe = msg.senderType == 'mitra'; + return _buildMessageBubble(msg, isMe); + }, + ), + ), + if (state.isOtherTyping) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text('Customer sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)), + ), + ), + _buildInputBar(), + ], + ); + } + + Widget _buildMessageBubble(ChatMessage msg, bool isMe) { + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75), + decoration: BoxDecoration( + color: isMe ? Colors.green.shade100 : Colors.grey.shade200, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(msg.content, style: const TextStyle(fontSize: 15)), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${msg.createdAt.hour.toString().padLeft(2, '0')}:${msg.createdAt.minute.toString().padLeft(2, '0')}', + style: const TextStyle(fontSize: 10, color: Colors.grey), + ), + if (isMe) ...[ + const SizedBox(width: 4), + _buildStatusIcon(msg.status), + ], + ], + ), + ], + ), + ), + ); + } + + Widget _buildStatusIcon(String status) { + switch (status) { + case 'sending': + return const Icon(Icons.access_time, size: 14, color: Colors.grey); + case 'sent': + return const Icon(Icons.check, size: 14, color: Colors.grey); + case 'delivered': + return const Icon(Icons.done_all, size: 14, color: Colors.grey); + case 'read': + return const Icon(Icons.done_all, size: 14, color: Colors.blue); + default: + return const SizedBox.shrink(); + } + } + + Widget _buildInputBar() { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + onChanged: _onTextChanged, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendMessage(), + decoration: InputDecoration( + hintText: 'Ketik pesan...', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.send, color: Colors.green), + onPressed: _sendMessage, + ), + ], + ), + ), + ); + } + + Widget _buildExtensionView(BuildContext context, Map request) { + final duration = request['duration_minutes'] as int?; + final extensionId = request['extension_id'] as String?; + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.timer, size: 64, color: Colors.orange), + const SizedBox(height: 16), + const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.green), + onPressed: () => context.read().add(RespondToExtension( + sessionId: widget.sessionId, + extensionId: extensionId!, + accepted: true, + )), + child: const Text('Terima', style: TextStyle(color: Colors.white)), + ), + const SizedBox(width: 16), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + onPressed: () => context.read().add(RespondToExtension( + sessionId: widget.sessionId, + extensionId: extensionId!, + accepted: false, + )), + child: const Text('Tolak', style: TextStyle(color: Colors.white)), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildGoodbyeView(BuildContext context, ExtensionState extState) { + final controller = TextEditingController(); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.waving_hand, size: 64, color: Colors.amber), + const SizedBox(height: 16), + const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('Tuliskan pesan terakhirmu untuk Customer', textAlign: TextAlign.center), + const SizedBox(height: 24), + TextField( + controller: controller, + maxLines: 3, + decoration: InputDecoration( + hintText: 'Terima kasih sudah curhat...', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: extState is ExtensionSubmitting + ? null + : () { + final text = controller.text.trim(); + if (text.isNotEmpty) { + context.read().add( + SubmitGoodbye(sessionId: widget.sessionId, message: text), + ); + } + }, + child: extState is ExtensionSubmitting + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Kirim & Selesai'), + ), + ], + ), + ), + ); + } +} diff --git a/mitra_app/lib/features/home/home_screen.dart b/mitra_app/lib/features/home/home_screen.dart index fe57cf4..e566c3f 100644 --- a/mitra_app/lib/features/home/home_screen.dart +++ b/mitra_app/lib/features/home/home_screen.dart @@ -170,13 +170,25 @@ class _StatusToggle extends StatelessWidget { class _ActiveSessionsButton extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( - child: ListTile( - leading: const Icon(Icons.chat_bubble_outline), - title: const Text('Sesi Aktif'), - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).pushNamed('/sessions'), - ), + return Column( + children: [ + Card( + child: ListTile( + leading: const Icon(Icons.chat_bubble_outline), + title: const Text('Sesi Aktif'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).pushNamed('/sessions'), + ), + ), + Card( + child: ListTile( + leading: const Icon(Icons.history), + title: const Text('Riwayat Chat'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).pushNamed('/chat/history'), + ), + ), + ], ); } } diff --git a/mitra_app/lib/main.dart b/mitra_app/lib/main.dart index 3ce3c97..42b057b 100644 --- a/mitra_app/lib/main.dart +++ b/mitra_app/lib/main.dart @@ -1,4 +1,5 @@ import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -6,12 +7,18 @@ import 'core/api/api_client.dart'; import 'core/auth/auth_bloc.dart'; import 'core/status/status_bloc.dart'; import 'core/chat/chat_request_bloc.dart'; +import 'core/chat/mitra_chat_bloc.dart'; +import 'core/chat/extension_bloc.dart'; import 'firebase_options.dart'; import 'router.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + + final messaging = FirebaseMessaging.instance; + await messaging.requestPermission(); + runApp(const App()); } @@ -38,6 +45,18 @@ class _AppState extends State with WidgetsBindingObserver { _router = buildRouter(_authBloc); _statusBloc = StatusBloc(apiClient: _apiClient); _chatRequestBloc = ChatRequestBloc(apiClient: _apiClient); + _registerFcmToken(); + } + + Future _registerFcmToken() { + return _authBloc.stream.where((s) => s is AuthAuthenticated).first.then((_) async { + try { + final token = await FirebaseMessaging.instance.getToken(); + if (token != null) { + await _apiClient.post('/api/shared/device-token', data: {'token': token}); + } + } catch (_) {} + }); } @override @@ -66,6 +85,8 @@ class _AppState extends State with WidgetsBindingObserver { BlocProvider.value(value: _authBloc), BlocProvider.value(value: _statusBloc), BlocProvider.value(value: _chatRequestBloc), + BlocProvider(create: (_) => MitraChatBloc(apiClient: _apiClient)), + BlocProvider(create: (_) => ExtensionBloc(apiClient: _apiClient)), RepositoryProvider.value(value: _apiClient), ], child: BlocListener( diff --git a/mitra_app/lib/router.dart b/mitra_app/lib/router.dart index d95af5d..cb5cbe2 100644 --- a/mitra_app/lib/router.dart +++ b/mitra_app/lib/router.dart @@ -6,6 +6,9 @@ import 'features/auth/screens/login_screen.dart'; import 'features/auth/screens/otp_screen.dart'; import 'features/home/home_screen.dart'; import 'features/chat/screens/active_sessions_screen.dart'; +import 'features/chat/screens/mitra_chat_screen.dart'; +import 'features/chat/screens/chat_history_screen.dart'; +import 'features/chat/screens/chat_transcript_screen.dart'; class _BlocRefreshNotifier extends ChangeNotifier { late final StreamSubscription _subscription; @@ -40,6 +43,17 @@ GoRouter buildRouter(AuthBloc authBloc) { GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)), GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), GoRoute(path: '/sessions', builder: (_, __) => const ActiveSessionsScreen()), + GoRoute(path: '/chat/session/:sessionId', builder: (context, state) { + final extra = state.extra as Map?; + return MitraChatScreen( + sessionId: state.pathParameters['sessionId']!, + customerName: extra?['customerName'] as String? ?? 'Customer', + ); + }), + GoRoute(path: '/chat/history', builder: (_, __) => const MitraChatHistoryScreen()), + GoRoute(path: '/chat/history/:sessionId', builder: (context, state) { + return MitraChatTranscriptScreen(sessionId: state.pathParameters['sessionId']!); + }), ], ); } diff --git a/mitra_app/pubspec.lock b/mitra_app/pubspec.lock index f5f70ea..3e6886f 100644 --- a/mitra_app/pubspec.lock +++ b/mitra_app/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" dio: dependency: "direct main" description: @@ -137,6 +145,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.17.5" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: a1662cc95d9750a324ad9df349b873360af6f11414902021f130c68ec02267c4 + url: "https://pub.dev" + source: hosted + version: "14.9.4" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "87c4a922cb6f811cfb7a889bdbb3622702443c52a0271636cbc90d813ceac147" + url: "https://pub.dev" + source: hosted + version: "4.5.37" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "0d34dca01a7b103ed7f20138bffbb28eb0e61a677bf9e78a028a932e2c7322d5" + url: "https://pub.dev" + source: hosted + version: "3.8.7" flutter: dependency: "direct main" description: flutter @@ -373,6 +405,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" + url: "https://pub.dev" + source: hosted + version: "2.4.5" sdks: dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/mitra_app/pubspec.yaml b/mitra_app/pubspec.yaml index 180a1d1..11f0c6a 100644 --- a/mitra_app/pubspec.yaml +++ b/mitra_app/pubspec.yaml @@ -14,9 +14,11 @@ dependencies: # Firebase firebase_core: ^2.27.1 firebase_auth: ^4.18.0 + firebase_messaging: ^14.7.15 - # HTTP + # HTTP & WebSocket dio: ^5.4.3 + web_socket_channel: ^2.4.5 # State management flutter_bloc: ^8.1.5 diff --git a/requirement/phase3-plan.md b/requirement/phase3-plan.md new file mode 100644 index 0000000..4356abe --- /dev/null +++ b/requirement/phase3-plan.md @@ -0,0 +1,475 @@ +# Phase 3 Implementation Plan: Chat Engine + +## Summary of Clarified Requirements + +| Topic | Decision | +|---|---| +| Chat opening | Time/price selection dialog before pairing | +| Price tiers (mock) | 15min/30k, 30min/60k, 45min/100k, 60min/150k, 24jam/250k | +| Free trial | One-time per customer; enabled/disabled globally via Control Center | +| Free trial duration | Single global config value (minutes) in Control Center | +| Payment timing | After pairing starts, but mocked in this phase | +| Chat transport | WebSocket (real-time) + FCM (background push notifications) | +| Message types | Text-only now; schema supports image/voice/video later | +| Emoji | Works natively (unicode text), no special handling needed | +| Message status | Sent (server ack), Delivered (client ack), Read (client opened) | +| Typing indicator | 3-second timeout, throttled (send at most once per 2-3s) | +| Chat history storage | Backend API as source of truth; no local cache | +| Session timer | Backend-authoritative (server-side countdown) | +| Timer warning | Show remaining time at 1 minute left | +| Extension timeout | 1 minute for customer to decide + mitra to confirm (configurable) | +| Chat during extension | Paused — no messages until extension confirmed or rejected | +| Early end | Mechanism built, disabled by default, configurable per role | +| Closing message | Free-text goodbye message from both parties | +| Chat history view | Full read-only transcript, kept forever | +| Deletion requests | Deferred to later phase | +| Sessions per customer | One active session at a time | +| WSS termination | Cloud Run handles TLS; backend uses plain `ws://` | +| Control center transcripts | Viewable by certain roles only (deferred to later phase) | +| Notification permission | Request on first app launch | + +--- + +## 1. Database Changes + +### 1.1 New table: `chat_messages` + +Stores all chat messages. + +| Column | Type | Notes | +|---|---|---| +| `id` | `SERIAL PRIMARY KEY` | | +| `session_id` | `INT REFERENCES chat_sessions(id)` | | +| `sender_type` | `VARCHAR` | `'customer'` or `'mitra'` | +| `sender_id` | `INT` | customer or mitra ID | +| `type` | `VARCHAR DEFAULT 'text'` | `'text'` now; `'image'`, `'voice'`, `'video'` later | +| `content` | `TEXT` | Message text (or file URL for future media) | +| `metadata` | `JSONB` | Nullable; for future media (file size, duration, thumbnail) | +| `status` | `VARCHAR DEFAULT 'sent'` | `'sent'`, `'delivered'`, `'read'` | +| `delivered_at` | `TIMESTAMPTZ` | When recipient's client acknowledged | +| `read_at` | `TIMESTAMPTZ` | When recipient opened/read the message | +| `created_at` | `TIMESTAMPTZ DEFAULT now()` | | + +**Indexes:** +- `(session_id, created_at)` — fetch messages in chronological order +- `(session_id, status)` — query undelivered/unread messages + +### 1.2 New table: `session_closures` + +Stores goodbye messages when a session ends. + +| Column | Type | Notes | +|---|---|---| +| `id` | `SERIAL PRIMARY KEY` | | +| `session_id` | `INT REFERENCES chat_sessions(id)` | | +| `user_type` | `VARCHAR` | `'customer'` or `'mitra'` | +| `user_id` | `INT` | customer or mitra ID | +| `message` | `TEXT` | Free-text goodbye message | +| `created_at` | `TIMESTAMPTZ DEFAULT now()` | | + +### 1.3 New table: `session_extensions` + +Tracks extension requests and their outcomes. + +| Column | Type | Notes | +|---|---|---| +| `id` | `SERIAL PRIMARY KEY` | | +| `session_id` | `INT REFERENCES chat_sessions(id)` | | +| `requested_duration_minutes` | `INT` | Duration customer selected | +| `requested_price` | `INT` | Mock price (in IDR) | +| `status` | `VARCHAR` | `'pending'`, `'accepted'`, `'rejected'`, `'timeout'` | +| `requested_at` | `TIMESTAMPTZ DEFAULT now()` | | +| `responded_at` | `TIMESTAMPTZ` | | + +### 1.4 Alter table: `chat_sessions` + +Add columns to support timed sessions: + +| Column | Type | Notes | +|---|---|---| +| `duration_minutes` | `INT` | Selected duration (15/30/45/60/1440) | +| `price` | `INT` | Mock price in IDR (0 for free trial) | +| `is_free_trial` | `BOOLEAN DEFAULT false` | | +| `expires_at` | `TIMESTAMPTZ` | Computed: `paired_at + duration_minutes` | +| `extended_minutes` | `INT DEFAULT 0` | Total extended time | + +Add new session statuses: +- `extending` — customer requested extension, waiting for mitra confirmation +- `closing` — session ended, waiting for goodbye messages + +### 1.5 New table: `customer_transactions` + +Tracks whether a customer has had any transaction (for free trial eligibility). + +| Column | Type | Notes | +|---|---|---| +| `id` | `SERIAL PRIMARY KEY` | | +| `customer_id` | `INT REFERENCES customers(id)` | | +| `session_id` | `INT REFERENCES chat_sessions(id)` | | +| `type` | `VARCHAR` | `'free_trial'`, `'paid'`, `'extension'` | +| `amount` | `INT` | 0 for free trial | +| `created_at` | `TIMESTAMPTZ DEFAULT now()` | | + +### 1.6 Extend `app_config` + +New config keys: + +| Key | Value (JSONB) | Purpose | +|---|---|---| +| `free_trial_enabled` | `{ "value": true }` | Enable/disable free trial globally | +| `free_trial_duration_minutes` | `{ "value": 5 }` | Free trial session duration | +| `extension_timeout_seconds` | `{ "value": 60 }` | Time limit for extension negotiation | +| `early_end_mitra_enabled` | `{ "value": false }` | Allow mitra to end session early | +| `early_end_customer_enabled` | `{ "value": false }` | Allow customer to end session early | + +--- + +## 2. Backend Changes + +### 2.1 WebSocket Setup + +- Add `@fastify/websocket` plugin (`src/plugins/websocket.js`) +- Single WebSocket endpoint: `GET /api/shared/ws` +- Connection authenticated via Firebase token (sent as query param or first message) +- After auth, server identifies user as customer or mitra and joins them to their session channel +- Valkey pub/sub remains the backend message bus; WebSocket is the client-facing transport +- Architecture: `Client ↔ WebSocket ↔ Backend ↔ Valkey pub/sub ↔ Backend ↔ WebSocket ↔ Other client` + +**WebSocket message types (JSON):** + +| Type | Direction | Purpose | +|---|---|---| +| `auth` | Client → Server | Authenticate with Firebase token | +| `auth_ok` | Server → Client | Authentication successful | +| `message` | Client → Server | Send a chat message | +| `message` | Server → Client | Receive a chat message | +| `message_status` | Server → Client | Delivery/read status update | +| `message_ack` | Server → Client | Server acknowledges sent message (sent status) | +| `typing` | Client → Server | User is typing | +| `typing` | Server → Client | Other user is typing | +| `session_timer` | Server → Client | Timer warning (1 min left) | +| `session_expired` | Server → Client | Session time is up | +| `extension_request` | Server → Client | Extension request notification (to mitra) | +| `extension_response` | Server → Client | Extension accepted/rejected (to customer) | +| `session_paused` | Server → Client | Chat paused during extension negotiation | +| `session_resumed` | Server → Client | Chat resumed after extension accepted | +| `session_closing` | Server → Client | Session ending, prompt for goodbye message | +| `early_end` | Client → Server | Request to end session early | +| `delivered` | Client → Server | Client acknowledges message delivery | +| `read` | Client → Server | Client marks messages as read | + +### 2.2 FCM Push Notification Setup + +- Use existing `firebase-admin` plugin for sending push notifications +- Store FCM device token per user (new columns on `customers` and `mitras` tables) +- Send push notification when recipient's WebSocket is not connected +- FCM payload includes: message preview, session ID, sender name +- On notification tap: deep-link to specific chat screen + +New columns: + +| Table | Column | Type | +|---|---|---| +| `customers` | `fcm_token` | `VARCHAR` | +| `mitras` | `fcm_token` | `VARCHAR` | + +New endpoint: + +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/api/shared/device-token` | Register/update FCM device token | + +### 2.3 New Public Routes — Chat Opening + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/api/client/chat/pricing` | Get mock price tiers + free trial eligibility | +| `POST` | `/api/client/chat/request` | Start pairing with selected duration/price (updated from Phase 2) | + +### 2.4 New Public Routes — Chat Messages + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/api/shared/chat/:sessionId/messages` | Fetch message history (paginated, for reconnect) | +| `GET` | `/api/shared/chat/:sessionId/info` | Get session info (timer, status, participants) | + +### 2.5 New Public Routes — Session Closure + +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/api/client/sessions/:sessionId/extend` | Customer requests extension with selected duration | +| `POST` | `/api/mitra/sessions/:sessionId/extend-response` | Mitra accepts/rejects extension | +| `POST` | `/api/shared/sessions/:sessionId/close-message` | Submit goodbye message | + +### 2.6 New Public Routes — Chat History + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/api/client/chat/history` | List past sessions (with mitra name, closure messages) | +| `GET` | `/api/mitra/chat/history` | List past sessions (with customer name, closure messages) | +| `GET` | `/api/shared/chat/:sessionId/transcript` | Full read-only chat transcript | + +### 2.7 New Internal Routes — Control Center + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/internal/config/free-trial` | Get free trial config | +| `PATCH` | `/internal/config/free-trial` | Update free trial enabled + duration | +| `GET` | `/internal/config/extension-timeout` | Get extension timeout config | +| `PATCH` | `/internal/config/extension-timeout` | Update extension timeout | +| `GET` | `/internal/config/early-end` | Get early end config | +| `PATCH` | `/internal/config/early-end` | Update early end per role | + +### 2.8 New Services + +| Service | Responsibilities | +|---|---| +| `chat.service.js` | Send message, update delivery/read status, fetch message history, typing event relay | +| `session-timer.service.js` | Backend-authoritative countdown, expiry check, 1-min warning trigger, extension timeout | +| `extension.service.js` | Extension request/response flow, session pause/resume, timeout handling | +| `closure.service.js` | Goodbye message submission, session completion, transaction recording | +| `notification.service.js` | FCM push notification sending, device token management, online/offline detection | +| `pricing.service.js` | Mock price tiers, free trial eligibility check | + +### 2.9 Chat Flow (Backend Detail) + +``` +Chat Opening: +1. Customer opens "Mulai Curhat" +2. Backend returns pricing tiers + free trial eligibility + - Free trial shown if: free_trial_enabled AND customer has 0 records in customer_transactions +3. Customer selects duration/price (or free trial) +4. POST /api/client/chat/request with { duration_minutes, price, is_free_trial } +5. Backend creates chat_session with duration_minutes, price, is_free_trial +6. Existing Phase 2 pairing flow proceeds (blast, accept, etc.) +7. On successful pairing: + - Set expires_at = now() + duration_minutes + - Create customer_transactions record + - Start server-side timer + +Chat Messaging: +1. Both parties connect via WebSocket after pairing +2. Client sends { type: 'message', content: '...' } +3. Backend saves to chat_messages (status: 'sent'), publishes via Valkey +4. Backend sends message_ack to sender (sent ✓) +5. Recipient's WebSocket receives message +6. Recipient sends { type: 'delivered' } → backend updates status, notifies sender (delivered ✓✓) +7. Recipient views message → sends { type: 'read' } → backend updates status (read ✓✓ blue) +8. If recipient offline → backend sends FCM push notification instead + +Session Expiry: +1. Backend timer fires 1 min before expires_at +2. Send session_timer to both clients via WebSocket +3. Both apps show countdown timer +4. At expires_at, backend sends session_expired +5. Customer gets extend/close dialog +6. If extend: POST /api/client/sessions/:id/extend + - Session status → extending, chat paused + - Mitra gets extension_request via WebSocket + - Mitra accepts/rejects within timeout (default 60s) + - If accept: extend expires_at, resume chat + - If reject or timeout: proceed to closure +7. If close (or after rejected extension): + - Session status → closing + - Both parties submit goodbye message + - Session status → completed + +Early End (when enabled): +1. User sends { type: 'early_end' } via WebSocket +2. Backend checks if early end is enabled for that role +3. If enabled: skip to closure flow (step 7 above) +4. If disabled: reject with error +``` + +### 2.10 Typing Indicator (Backend Detail) + +``` +1. Client sends { type: 'typing' } via WebSocket +2. Backend relays to other party via Valkey pub/sub → WebSocket +3. Receiving client shows typing indicator +4. Receiving client auto-hides after 3 seconds of no new typing event +5. Sending client throttles: at most one typing event per 2 seconds +``` + +--- + +## 3. Client App Changes + +### 3.1 New BLoC: `ChatBloc` + +Manages active chat messaging. + +**Events:** `ConnectWebSocket`, `DisconnectWebSocket`, `SendMessage`, `MessageReceived`, `MessageStatusUpdate`, `TypingStarted`, `TypingStopped`, `SessionTimerWarning`, `SessionExpired` +**States:** `ChatInitial`, `ChatConnecting`, `ChatConnected(messages)`, `ChatTimerWarning(remaining)`, `ChatSessionExpired`, `ChatError` + +- On `ConnectWebSocket` → authenticate, load message history from API, listen for incoming messages +- On `SendMessage` → send via WebSocket, add to local message list with "sending" state +- On `MessageReceived` → add to list, send delivery acknowledgment +- On `MessageStatusUpdate` → update message status (sent → delivered → read) + +### 3.2 New BLoC: `ChatOpeningBloc` + +Manages pricing selection and free trial. + +**Events:** `LoadPricing`, `SelectTier`, `SelectFreeTrial`, `ConfirmSelection` +**States:** `PricingLoading`, `PricingLoaded(tiers, freeTrialEligible)`, `TierSelected(tier)`, `PricingError` + +- On `LoadPricing` → call `/api/client/chat/pricing` +- On `ConfirmSelection` → trigger existing `PairingBloc.RequestPairing` with duration/price + +### 3.3 New BLoC: `SessionClosureBloc` + +Manages extension and goodbye flow. + +**Events:** `SessionExpired`, `RequestExtension`, `ExtensionResult`, `SubmitGoodbye` +**States:** `ClosureInitial`, `ShowExtendDialog`, `ExtendingWaitingMitra`, `ExtensionAccepted`, `ExtensionRejected`, `ShowGoodbyeInput`, `ClosureComplete` + +### 3.4 Screen Changes + +| Screen | Changes | +|---|---| +| Home screen | "Mulai Curhat" opens pricing dialog instead of directly pairing | +| New: Pricing dialog | Bottom sheet with 5 price tiers + free trial option (if eligible) | +| New: Chat screen | Full chat UI: message list, text input, send button, typing indicator | +| Chat screen | Message bubbles with status icons (✓ sent, ✓✓ delivered, ✓✓ blue read) | +| Chat screen | Countdown timer overlay at 1 minute remaining | +| New: Extension dialog | "Extend session?" with price tier selection | +| New: Waiting mitra dialog | "Menunggu konfirmasi Bestie..." with timeout | +| New: Goodbye screen | Free-text input for closing message | +| New: Chat history list | List of past sessions (bestie name, date, goodbye message) | +| New: Chat transcript screen | Read-only scrollable chat history | + +### 3.5 Navigation Updates + +New routes in GoRouter: + +- `/chat/pricing` — pricing selection dialog/screen +- `/chat/session/:sessionId` — active chat screen (updated from Phase 2) +- `/chat/history` — chat history list +- `/chat/history/:sessionId` — read-only transcript + +### 3.6 FCM Setup + +- Request notification permission on first app launch +- Register device token via `POST /api/shared/device-token` +- Handle incoming notifications: tap → navigate to specific chat screen +- Update token on app launch (tokens can rotate) + +--- + +## 4. Mitra App Changes + +### 4.1 New BLoC: `ChatBloc` + +Same as client app — manages active chat messaging. Shared message type structure. + +**Events/States:** Same as client app ChatBloc. + +### 4.2 New BLoC: `ExtensionBloc` + +Handles incoming extension requests from customers. + +**Events:** `ExtensionReceived`, `AcceptExtension`, `RejectExtension`, `ExtensionTimeout` +**States:** `ExtensionIdle`, `ExtensionPending(duration, price)`, `ExtensionAccepted`, `ExtensionRejected` + +### 4.3 Screen Changes + +| Screen | Changes | +|---|---| +| Home screen | Active sessions list → tap to open chat | +| New: Chat screen | Full chat UI (same as client app) | +| Chat screen | Extension request overlay when customer requests extension | +| New: Extension dialog | "Customer ingin perpanjang X menit" with Accept/Reject buttons | +| New: Goodbye screen | Free-text input for closing message | +| New: Chat history list | List of past sessions (customer name, date, goodbye message) | +| New: Chat transcript screen | Read-only scrollable chat history | + +### 4.4 Navigation Updates + +New routes in GoRouter: + +- `/chat/session/:sessionId` — active chat screen +- `/chat/history` — chat history list +- `/chat/history/:sessionId` — read-only transcript + +### 4.5 FCM Setup + +- Same as client app: request permission on launch, register token +- Push notification on incoming message when app is backgrounded +- Tap notification → navigate directly to specific customer's chat screen (not just chat list) + +--- + +## 5. Control Center Changes + +### 5.1 Updated Pages + +| Page | Changes | +|---|---| +| Settings page | Add: free trial toggle + duration, extension timeout, early end toggles (mitra/customer) | +| Session detail page | Add: view chat transcript link (role-restricted, deferred) | + +### 5.2 No New Pages This Phase + +Chat transcript viewing for admins is deferred. Config controls are added to the existing Settings page. + +--- + +## 6. Implementation Order + +| Step | What | Apps affected | +|---|---|---| +| 1 | Database migration (new tables, altered columns, new config keys) | Backend | +| 2 | WebSocket plugin setup (`@fastify/websocket`) | Backend | +| 3 | Pricing service + free trial eligibility + chat opening API | Backend | +| 4 | Chat message service + WebSocket message handling | Backend | +| 5 | Message delivery/read status tracking | Backend | +| 6 | Session timer service (backend-authoritative countdown) | Backend | +| 7 | Extension service (request/response/timeout) | Backend | +| 8 | Closure service (goodbye messages, session completion) | Backend | +| 9 | FCM notification service + device token endpoint | Backend | +| 10 | Client app: FCM setup + pricing dialog + ChatOpeningBloc | Client app | +| 11 | Client app: ChatBloc + chat screen + message status UI | Client app | +| 12 | Client app: timer warning + extension dialog + SessionClosureBloc | Client app | +| 13 | Client app: goodbye screen + chat history screens | Client app | +| 14 | Mitra app: FCM setup + ChatBloc + chat screen | Mitra app | +| 15 | Mitra app: ExtensionBloc + extension dialog | Mitra app | +| 16 | Mitra app: goodbye screen + chat history screens | Mitra app | +| 17 | Control center: settings page updates (free trial, extension, early end) | Control center | +| 18 | Typing indicator (WebSocket relay, throttle, 3s timeout) | Backend + both apps | + +--- + +## 7. New Dependencies + +| App | Package | Purpose | +|---|---|---| +| Backend | `@fastify/websocket` | WebSocket support (built on `ws`) | +| Backend | `firebase-admin` (existing) | FCM push notifications | +| Client app | `web_socket_channel` | WebSocket client | +| Client app | `firebase_messaging` | FCM push notifications | +| Mitra app | `web_socket_channel` | WebSocket client | +| Mitra app | `firebase_messaging` | FCM push notifications | + +--- + +## 8. Notes for Future Phases + +**Media messages (image, voice clip, video clip):** +- Add file upload endpoint (Cloud Storage) +- Add new `type` values to `chat_messages` +- Add media bubble widgets in Flutter +- No architectural changes — same WebSocket transport, same delivery status, same history + +**Chat transcript for Control Center:** +- Add role-based access check +- Reuse existing `/api/shared/chat/:sessionId/transcript` endpoint on internal routes + +**Deletion requests:** +- Add customer request flow + admin approval +- Soft-delete or anonymize messages in `chat_messages` + +**Payment integration (Xendit):** +- Replace mock pricing with real payment flow +- Integrate at the `pending_payment` status transition point +- Extension payments follow same flow