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) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:58:11 +08:00
parent 844d7234e6
commit b4efcf14c2
47 changed files with 4361 additions and 44 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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()
}
})
})
}

View File

@@ -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 })
})
}

View File

@@ -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 })
})
}

View File

@@ -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 })
})
}

View File

@@ -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 })
})
}

View File

@@ -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 {

View File

@@ -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,
})
}

View File

@@ -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
`
}

View File

@@ -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
`
}

View File

@@ -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()
}

View File

@@ -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,
})
}

View File

@@ -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
}
}

View File

@@ -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}

View File

@@ -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 },
}
}

View File

@@ -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)`)
}
}

View File

@@ -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 }
}

View File

@@ -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<Object?> get props => [];
}
class ConnectChat extends ChatEvent {
final String sessionId;
ConnectChat(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
class DisconnectChat extends ChatEvent {}
class SendMessage extends ChatEvent {
final String content;
SendMessage(this.content);
@override
List<Object?> get props => [content];
}
class SendTyping extends ChatEvent {}
class _MessageReceived extends ChatEvent {
final Map<String, dynamic> data;
_MessageReceived(this.data);
@override
List<Object?> get props => [data];
}
class _ConnectionError extends ChatEvent {}
class MarkMessagesDelivered extends ChatEvent {
final List<String> messageIds;
MarkMessagesDelivered(this.messageIds);
@override
List<Object?> get props => [messageIds];
}
class MarkMessagesRead extends ChatEvent {
final List<String> messageIds;
MarkMessagesRead(this.messageIds);
@override
List<Object?> get props => [messageIds];
}
// States
abstract class ChatState extends Equatable {
@override
List<Object?> get props => [];
}
class ChatInitial extends ChatState {}
class ChatConnecting extends ChatState {}
class ChatConnected extends ChatState {
final List<ChatMessage> messages;
final bool isOtherTyping;
final int? remainingSeconds;
final bool sessionExpired;
final bool sessionPaused;
final bool sessionClosing;
final Map<String, dynamic>? extensionResponse;
ChatConnected({
required this.messages,
this.isOtherTyping = false,
this.remainingSeconds,
this.sessionExpired = false,
this.sessionPaused = false,
this.sessionClosing = false,
this.extensionResponse,
});
ChatConnected copyWith({
List<ChatMessage>? messages,
bool? isOtherTyping,
int? remainingSeconds,
bool? sessionExpired,
bool? sessionPaused,
bool? sessionClosing,
Map<String, dynamic>? 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<Object?> get props => [messages, isOtherTyping, remainingSeconds, sessionExpired, sessionPaused, sessionClosing, extensionResponse];
}
class ChatError extends ChatState {
final String message;
ChatError(this.message);
@override
List<Object?> 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<ChatEvent, ChatState> {
final ApiClient apiClient;
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
Timer? _typingTimer;
ChatBloc({required this.apiClient}) : super(ChatInitial()) {
on<ConnectChat>(_onConnect);
on<DisconnectChat>(_onDisconnect);
on<SendMessage>(_onSendMessage);
on<SendTyping>(_onSendTyping);
on<_MessageReceived>(_onMessageReceived);
on<_ConnectionError>(_onConnectionError);
on<MarkMessagesDelivered>(_onMarkDelivered);
on<MarkMessagesRead>(_onMarkRead);
}
Future<void> _onConnect(ConnectChat event, Emitter<ChatState> 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<dynamic>;
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<String, dynamic>;
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<ChatState> emit) {
_cleanup();
emit(ChatInitial());
}
void _onSendMessage(SendMessage event, Emitter<ChatState> 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<ChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': 'typing'}));
}
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<ChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({
'type': 'delivered',
'message_ids': event.messageIds,
}));
}
void _onMarkRead(MarkMessagesRead event, Emitter<ChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({
'type': 'read',
'message_ids': event.messageIds,
}));
}
void _onMessageReceived(_MessageReceived event, Emitter<ChatState> 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<dynamic>).cast<String>();
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<ChatState> emit) {
// Could implement reconnection logic here
}
void _cleanup() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
_typingTimer?.cancel();
_typingTimer = null;
}
@override
Future<void> close() {
_cleanup();
return super.close();
}
}

View File

@@ -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<Object?> get props => [];
}
class LoadPricing extends ChatOpeningEvent {}
// States
abstract class ChatOpeningState extends Equatable {
@override
List<Object?> get props => [];
}
class PricingInitial extends ChatOpeningState {}
class PricingLoading extends ChatOpeningState {}
class PricingLoaded extends ChatOpeningState {
final List<PriceTier> tiers;
final bool freeTrialEligible;
final int freeTrialDurationMinutes;
PricingLoaded({
required this.tiers,
required this.freeTrialEligible,
this.freeTrialDurationMinutes = 5,
});
@override
List<Object?> get props => [tiers, freeTrialEligible, freeTrialDurationMinutes];
}
class PricingError extends ChatOpeningState {
final String message;
PricingError(this.message);
@override
List<Object?> 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<String, dynamic> json) {
return PriceTier(
durationMinutes: json['duration_minutes'] as int,
price: json['price'] as int,
label: json['label'] as String,
);
}
}
// Bloc
class ChatOpeningBloc extends Bloc<ChatOpeningEvent, ChatOpeningState> {
final ApiClient apiClient;
ChatOpeningBloc({required this.apiClient}) : super(PricingInitial()) {
on<LoadPricing>(_onLoadPricing);
}
Future<void> _onLoadPricing(LoadPricing event, Emitter<ChatOpeningState> emit) async {
emit(PricingLoading());
try {
final response = await apiClient.get('/api/client/chat/pricing');
final data = response['data'] as Map<String, dynamic>;
final tiersJson = data['tiers'] as List<dynamic>;
final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map<String, dynamic>)).toList();
final freeTrial = data['free_trial'] as Map<String, dynamic>;
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.'));
}
}
}

View File

@@ -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<Object?> 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<Object?> 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<Object?> get props => [sessionId, message];
}
// States
abstract class SessionClosureState extends Equatable {
@override
List<Object?> 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<Object?> get props => [message];
}
// Bloc
class SessionClosureBloc extends Bloc<SessionClosureEvent, SessionClosureState> {
final ApiClient apiClient;
SessionClosureBloc({required this.apiClient}) : super(ClosureInitial()) {
on<RequestExtension>(_onRequestExtension);
on<DeclineExtension>(_onDeclineExtension);
on<SubmitGoodbye>(_onSubmitGoodbye);
}
Future<void> _onRequestExtension(RequestExtension event, Emitter<SessionClosureState> 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<SessionClosureState> emit) {
emit(ClosureShowGoodbye());
}
Future<void> _onSubmitGoodbye(SubmitGoodbye event, Emitter<SessionClosureState> 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.'));
}
}
}

View File

@@ -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<Object?> get props => [durationMinutes, price, isFreeTrial];
}
class CancelPairing extends PairingEvent {}
class _PairingStatusUpdate extends PairingEvent {
@@ -71,29 +81,42 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
PairingBloc({required this.apiClient}) : super(PairingInitial()) {
on<RequestPairing>(_onRequestPairing);
on<RequestPairingWithTier>(_onRequestPairingWithTier);
on<CancelPairing>(_onCancelPairing);
on<_PairingStatusUpdate>(_onStatusUpdate);
on<_PairingTimeout>(_onTimeout);
}
Future<void> _onRequestPairing(RequestPairing event, Emitter<PairingState> emit) async {
// Reset to initial so BlocListener can detect new errors
await _doPairingRequest(emit, {});
}
Future<void> _onRequestPairingWithTier(RequestPairingWithTier event, Emitter<PairingState> emit) async {
final body = <String, dynamic>{};
if (event.isFreeTrial) {
body['is_free_trial'] = true;
} else {
body['duration_minutes'] = event.durationMinutes;
body['price'] = event.price;
}
await _doPairingRequest(emit, body);
}
Future<void> _doPairingRequest(Emitter<PairingState> emit, Map<String, dynamic> 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<String, dynamic>;
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<PairingEvent, PairingState> {
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.'));
}

View File

@@ -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<ChatHistoryScreen> createState() => _ChatHistoryScreenState();
}
class _ChatHistoryScreenState extends State<ChatHistoryScreen> {
List<Map<String, dynamic>> _sessions = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadHistory();
}
Future<void> _loadHistory() async {
try {
final api = context.read<ApiClient>();
final response = await api.get('/api/client/chat/history');
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
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']}'),
);
},
),
);
}
}

View File

@@ -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<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
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<ChatBloc>().add(SendTyping());
_typingThrottle = Timer(const Duration(seconds: 2), () {});
}
void _sendMessage() {
final text = _messageController.text.trim();
if (text.isEmpty) return;
context.read<ChatBloc>().add(SendMessage(text));
_messageController.clear();
_scrollToBottom();
}
@override
Widget build(BuildContext context) {
return MultiBlocListener(
listeners: [
BlocListener<ChatBloc, ChatState>(
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<SessionClosureBloc>().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<ChatBloc>().add(MarkMessagesRead(unread));
}
}
},
),
BlocListener<SessionClosureBloc, SessionClosureState>(
listener: (context, state) {
if (state is ClosureComplete) {
context.go('/home');
}
},
),
],
child: Scaffold(
appBar: AppBar(
title: Text(widget.mitraName),
automaticallyImplyLeading: false,
actions: [
BlocBuilder<ChatBloc, ChatState>(
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<ChatBloc, ChatState>(
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<SessionClosureBloc>().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<SessionClosureBloc>().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<SessionClosureBloc>().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)),
],
),
),
);
}
}

View File

@@ -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<ChatTranscriptScreen> createState() => _ChatTranscriptScreenState();
}
class _ChatTranscriptScreenState extends State<ChatTranscriptScreen> {
List<Map<String, dynamic>> _messages = [];
List<Map<String, dynamic>> _closures = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadTranscript();
}
Future<void> _loadTranscript() async {
try {
final api = context.read<ApiClient>();
final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript');
final data = response['data'] as Map<String, dynamic>;
setState(() {
_messages = (data['messages'] as List<dynamic>).cast<Map<String, dynamic>>();
_closures = (data['closures'] as List<dynamic>).cast<Map<String, dynamic>>();
_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),
),
)),
],
],
),
);
}
}

View File

@@ -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<void> show(BuildContext context) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => BlocProvider(
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..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<ChatOpeningBloc, ChatOpeningState>(
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<PairingBloc>().add(RequestPairingWithTier(
durationMinutes: durationMinutes,
price: price,
isFreeTrial: isFreeTrial,
));
}
}

View File

@@ -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<AuthBloc>().add(LogoutRequested()),
@@ -51,7 +56,7 @@ class HomeScreen extends StatelessWidget {
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
),
onPressed: () => context.read<PairingBloc>().add(RequestPairing()),
onPressed: () => PricingBottomSheet.show(context),
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
),
],

View File

@@ -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<App> {
super.initState();
_authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted());
_router = buildRouter(_authBloc);
_registerFcmToken();
}
Future<void> _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<App> {
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(

View File

@@ -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<String, dynamic>?;
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']!);
}),
],
);
}

View File

@@ -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"))

View File

@@ -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:

View File

@@ -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

View File

@@ -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 <div>Loading...</div>
// 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 <div>Loading...</div>
return (
<div>
@@ -80,6 +141,80 @@ export default function SettingsPage() {
</div>
{maxMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Free Trial</h2>
<p>Aktifkan free trial untuk customer baru yang belum pernah bertransaksi.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
type="checkbox"
checked={ftData?.enabled ?? false}
onChange={e => ftMutation.mutate({ enabled: e.target.checked })}
disabled={ftMutation.isPending}
/>
Aktifkan Free Trial
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label>Durasi:</label>
<input
type="number"
min="1"
value={ftData?.duration_minutes ?? 5}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 1) ftMutation.mutate({ duration_minutes: val })
}}
disabled={ftMutation.isPending}
style={{ width: 80 }}
/>
<span>menit</span>
</div>
{ftMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Extension Timeout</h2>
<p>Waktu tunggu untuk customer memutuskan dan mitra mengkonfirmasi perpanjangan sesi.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="10"
value={etData?.extension_timeout_seconds ?? 60}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 10) etMutation.mutate(val)
}}
disabled={etMutation.isPending}
style={{ width: 80 }}
/>
<span>detik</span>
</div>
{etMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Akhiri Sesi Lebih Awal</h2>
<p>Izinkan mitra dan/atau customer untuk mengakhiri sesi sebelum waktu habis.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
type="checkbox"
checked={eeData?.mitra_enabled ?? false}
onChange={e => eeMutation.mutate({ mitra_enabled: e.target.checked })}
disabled={eeMutation.isPending}
/>
Izinkan Mitra mengakhiri lebih awal
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={eeData?.customer_enabled ?? false}
onChange={e => eeMutation.mutate({ customer_enabled: e.target.checked })}
disabled={eeMutation.isPending}
/>
Izinkan Customer mengakhiri lebih awal
</label>
{eeMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
</div>
)
}

View File

@@ -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<Object?> 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<Object?> get props => [sessionId, extensionId, accepted];
}
class SubmitGoodbye extends ExtensionEvent {
final String sessionId;
final String message;
SubmitGoodbye({required this.sessionId, required this.message});
@override
List<Object?> get props => [sessionId, message];
}
// States
abstract class ExtensionState extends Equatable {
@override
List<Object?> 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<Object?> get props => [message];
}
// Bloc
class ExtensionBloc extends Bloc<ExtensionEvent, ExtensionState> {
final ApiClient apiClient;
ExtensionBloc({required this.apiClient}) : super(ExtensionIdle()) {
on<RespondToExtension>(_onRespond);
on<SubmitGoodbye>(_onSubmitGoodbye);
}
Future<void> _onRespond(RespondToExtension event, Emitter<ExtensionState> 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<void> _onSubmitGoodbye(SubmitGoodbye event, Emitter<ExtensionState> 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.'));
}
}
}

View File

@@ -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<Object?> get props => [];
}
class ConnectChat extends MitraChatEvent {
final String sessionId;
ConnectChat(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
class DisconnectChat extends MitraChatEvent {}
class SendMessage extends MitraChatEvent {
final String content;
SendMessage(this.content);
@override
List<Object?> get props => [content];
}
class SendTyping extends MitraChatEvent {}
class _MessageReceived extends MitraChatEvent {
final Map<String, dynamic> data;
_MessageReceived(this.data);
@override
List<Object?> get props => [data];
}
class _ConnectionError extends MitraChatEvent {}
class MarkMessagesDelivered extends MitraChatEvent {
final List<String> messageIds;
MarkMessagesDelivered(this.messageIds);
@override
List<Object?> get props => [messageIds];
}
class MarkMessagesRead extends MitraChatEvent {
final List<String> messageIds;
MarkMessagesRead(this.messageIds);
@override
List<Object?> get props => [messageIds];
}
// States
abstract class MitraChatState extends Equatable {
@override
List<Object?> get props => [];
}
class ChatInitial extends MitraChatState {}
class ChatConnecting extends MitraChatState {}
class ChatConnected extends MitraChatState {
final List<ChatMessage> messages;
final bool isOtherTyping;
final int? remainingSeconds;
final bool sessionExpired;
final bool sessionClosing;
final Map<String, dynamic>? extensionRequest;
ChatConnected({
required this.messages,
this.isOtherTyping = false,
this.remainingSeconds,
this.sessionExpired = false,
this.sessionClosing = false,
this.extensionRequest,
});
ChatConnected copyWith({
List<ChatMessage>? messages,
bool? isOtherTyping,
int? remainingSeconds,
bool? sessionExpired,
bool? sessionClosing,
Map<String, dynamic>? 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<Object?> get props => [messages, isOtherTyping, remainingSeconds, sessionExpired, sessionClosing, extensionRequest];
}
class ChatError extends MitraChatState {
final String message;
ChatError(this.message);
@override
List<Object?> 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<MitraChatEvent, MitraChatState> {
final ApiClient apiClient;
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
Timer? _typingTimer;
MitraChatBloc({required this.apiClient}) : super(ChatInitial()) {
on<ConnectChat>(_onConnect);
on<DisconnectChat>(_onDisconnect);
on<SendMessage>(_onSendMessage);
on<SendTyping>(_onSendTyping);
on<_MessageReceived>(_onMessageReceived);
on<_ConnectionError>(_onConnectionError);
on<MarkMessagesDelivered>(_onMarkDelivered);
on<MarkMessagesRead>(_onMarkRead);
}
Future<void> _onConnect(ConnectChat event, Emitter<MitraChatState> emit) async {
emit(ChatConnecting());
try {
final response = await apiClient.get('/api/shared/chat/${event.sessionId}/messages');
final messagesData = response['data'] as List<dynamic>;
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<String, dynamic>;
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<MitraChatState> emit) {
_cleanup();
emit(ChatInitial());
}
void _onSendMessage(SendMessage event, Emitter<MitraChatState> 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<MitraChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': 'typing'}));
}
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<MitraChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': 'delivered', 'message_ids': event.messageIds}));
}
void _onMarkRead(MarkMessagesRead event, Emitter<MitraChatState> emit) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': 'read', 'message_ids': event.messageIds}));
}
void _onMessageReceived(_MessageReceived event, Emitter<MitraChatState> 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<dynamic>).cast<String>();
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<MitraChatState> emit) {}
void _cleanup() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
_typingTimer?.cancel();
_typingTimer = null;
}
@override
Future<void> close() {
_cleanup();
return super.close();
}
}

View File

@@ -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<MitraChatHistoryScreen> createState() => _MitraChatHistoryScreenState();
}
class _MitraChatHistoryScreenState extends State<MitraChatHistoryScreen> {
List<Map<String, dynamic>> _sessions = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadHistory();
}
Future<void> _loadHistory() async {
try {
final api = context.read<ApiClient>();
final response = await api.get('/api/mitra/chat-requests/history');
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
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']}'),
);
},
),
);
}
}

View File

@@ -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<MitraChatTranscriptScreen> createState() => _MitraChatTranscriptScreenState();
}
class _MitraChatTranscriptScreenState extends State<MitraChatTranscriptScreen> {
List<Map<String, dynamic>> _messages = [];
List<Map<String, dynamic>> _closures = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadTranscript();
}
Future<void> _loadTranscript() async {
try {
final api = context.read<ApiClient>();
final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript');
final data = response['data'] as Map<String, dynamic>;
setState(() {
_messages = (data['messages'] as List<dynamic>).cast<Map<String, dynamic>>();
_closures = (data['closures'] as List<dynamic>).cast<Map<String, dynamic>>();
_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),
),
)),
],
],
),
);
}
}

View File

@@ -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<MitraChatScreen> createState() => _MitraChatScreenState();
}
class _MitraChatScreenState extends State<MitraChatScreen> {
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<MitraChatBloc>().add(SendTyping());
_typingThrottle = Timer(const Duration(seconds: 2), () {});
}
void _sendMessage() {
final text = _messageController.text.trim();
if (text.isEmpty) return;
context.read<MitraChatBloc>().add(SendMessage(text));
_messageController.clear();
_scrollToBottom();
}
@override
Widget build(BuildContext context) {
return MultiBlocListener(
listeners: [
BlocListener<MitraChatBloc, MitraChatState>(
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<MitraChatBloc>().add(MarkMessagesRead(unread));
}
if (state.sessionClosing) {
// Trigger goodbye view
}
}
},
),
BlocListener<ExtensionBloc, ExtensionState>(
listener: (context, state) {
if (state is ExtensionComplete) {
context.go('/home');
}
},
),
],
child: Scaffold(
appBar: AppBar(
title: Text(widget.customerName),
actions: [
BlocBuilder<MitraChatBloc, MitraChatState>(
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<MitraChatBloc, MitraChatState>(
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<ExtensionBloc>().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<String, dynamic> 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<ExtensionBloc>().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<ExtensionBloc>().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<ExtensionBloc>().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'),
),
],
),
),
);
}
}

View File

@@ -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'),
),
),
],
);
}
}

View File

@@ -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<App> with WidgetsBindingObserver {
_router = buildRouter(_authBloc);
_statusBloc = StatusBloc(apiClient: _apiClient);
_chatRequestBloc = ChatRequestBloc(apiClient: _apiClient);
_registerFcmToken();
}
Future<void> _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<App> 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<AuthBloc, AuthState>(

View File

@@ -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<String, dynamic>?;
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']!);
}),
],
);
}

View File

@@ -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"

View File

@@ -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

475
requirement/phase3-plan.md Normal file
View File

@@ -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