Phase 2 scaffold: mitra online status & pairing logic

Add mitra online/offline status with heartbeat-based auto-offline,
customer-mitra pairing via Valkey pub/sub blast, session management,
and control center dashboard with real-time stats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 23:17:49 +08:00
parent a7a2a32d27
commit d668112edd
44 changed files with 2800 additions and 80 deletions

View File

@@ -30,7 +30,8 @@ Mental health chat platform connecting clients (users seeking support) with trai
- **Control center is internal-only** — never expose its API routes to the public internet; protected via Nginx allow/deny + VPN - **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 - **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 - **Horizontal scaling** (Cloud Run) handles load — do not split into microservices prematurely
- **Real-time features** (chat) are deferred to requirements phase - **Real-time features** use Valkey pub/sub for in-app events; FCM push notifications planned for next phase
- **Pairing** uses blast-to-all-available-mitras with first-come-first-served acceptance
## Current Progress ## Current Progress
@@ -40,7 +41,12 @@ Mental health chat platform connecting clients (users seeking support) with trai
- mitra_app: OTP-only login - mitra_app: OTP-only login
- control_center: email/password login, mitra management, user management, anonymity settings - control_center: email/password login, mitra management, user management, anonymity settings
- Docs: `requirement/phase1-plan.md`, `requirement/phase1-api-contract.md`, `requirement/client_app_mockup.html` - Docs: `requirement/phase1-plan.md`, `requirement/phase1-api-contract.md`, `requirement/client_app_mockup.html`
- **Phase 2 (Sessions, Chat, Payments)** — not yet started, requirements not yet written - **Phase 2 (Mitra Online Status & Pairing)** — fully scaffolded
- Backend: Valkey pub/sub, mitra online/offline status + heartbeat + auto-offline, pairing service, session management, dashboard stats
- client_app: "Mulai Curhat" CTA, searching/found/no-bestie/session-active screens, PairingBloc
- mitra_app: online/offline toggle, heartbeat + lifecycle handling, incoming request notification, active sessions screen
- control_center: dashboard (auto-refresh), max customers per mitra config, session management + reroute, mitra online logs
- Docs: `requirement/phase2.md`, `requirement/phase2-plan.md`
## Domain Concepts ## Domain Concepts

View File

@@ -6,6 +6,9 @@ INTERNAL_HOST=127.0.0.1
# Database # Database
DATABASE_URL=postgresql://user:password@localhost:5432/halobestie DATABASE_URL=postgresql://user:password@localhost:5432/halobestie
# Valkey / Redis
VALKEY_URL=redis://localhost:6379
# Firebase # Firebase
FIREBASE_PROJECT_ID=your-firebase-project-id FIREBASE_PROJECT_ID=your-firebase-project-id
FIREBASE_CLIENT_EMAIL=your-service-account@project.iam.gserviceaccount.com FIREBASE_CLIENT_EMAIL=your-service-account@project.iam.gserviceaccount.com

View File

@@ -14,6 +14,7 @@
"fastify": "^4.28.1", "fastify": "^4.28.1",
"@fastify/sensible": "^5.6.0", "@fastify/sensible": "^5.6.0",
"firebase-admin": "^12.2.0", "firebase-admin": "^12.2.0",
"ioredis": "^5.4.1",
"pg": "^8.12.0", "pg": "^8.12.0",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"zod": "^3.23.8", "zod": "^3.23.8",

View File

@@ -5,6 +5,7 @@ import { ccUserRoutes } from './routes/internal/cc-user.routes.js'
import { rolesRoutes } from './routes/internal/roles.routes.js' import { rolesRoutes } from './routes/internal/roles.routes.js'
import { internalAuthRoutes } from './routes/internal/auth.routes.js' import { internalAuthRoutes } from './routes/internal/auth.routes.js'
import { internalConfigRoutes } from './routes/internal/config.routes.js' import { internalConfigRoutes } from './routes/internal/config.routes.js'
import { sessionManagementRoutes } from './routes/internal/session.routes.js'
import { errorHandler } from './plugins/error-handler.js' import { errorHandler } from './plugins/error-handler.js'
export const buildInternalApp = async () => { export const buildInternalApp = async () => {
@@ -18,6 +19,7 @@ export const buildInternalApp = async () => {
app.register(ccUserRoutes, { prefix: '/internal/control-center-users' }) app.register(ccUserRoutes, { prefix: '/internal/control-center-users' })
app.register(rolesRoutes, { prefix: '/internal/roles' }) app.register(rolesRoutes, { prefix: '/internal/roles' })
app.register(internalConfigRoutes, { prefix: '/internal/config' }) app.register(internalConfigRoutes, { prefix: '/internal/config' })
app.register(sessionManagementRoutes, { prefix: '/internal/sessions' })
return app return app
} }

View File

@@ -4,6 +4,9 @@ import { customerRoutes } from './routes/public/customer.routes.js'
import { clientAuthRoutes } from './routes/public/client.auth.routes.js' import { clientAuthRoutes } from './routes/public/client.auth.routes.js'
import { mitraAuthRoutes } from './routes/public/mitra.auth.routes.js' import { mitraAuthRoutes } from './routes/public/mitra.auth.routes.js'
import { sharedConfigRoutes } from './routes/public/shared.config.routes.js' import { sharedConfigRoutes } from './routes/public/shared.config.routes.js'
import { mitraStatusRoutes } from './routes/public/mitra.status.routes.js'
import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js'
import { clientChatRoutes } from './routes/public/client.chat.routes.js'
import { errorHandler } from './plugins/error-handler.js' import { errorHandler } from './plugins/error-handler.js'
export const buildPublicApp = async () => { export const buildPublicApp = async () => {
@@ -16,6 +19,9 @@ export const buildPublicApp = async () => {
app.register(sharedConfigRoutes, { prefix: '/api/shared/config' }) app.register(sharedConfigRoutes, { prefix: '/api/shared/config' })
app.register(clientAuthRoutes, { prefix: '/api/client/auth' }) app.register(clientAuthRoutes, { prefix: '/api/client/auth' })
app.register(mitraAuthRoutes, { prefix: '/api/mitra/auth' }) app.register(mitraAuthRoutes, { prefix: '/api/mitra/auth' })
app.register(mitraStatusRoutes, { prefix: '/api/mitra/status' })
app.register(mitraChatRoutes, { prefix: '/api/mitra/chat-requests' })
app.register(clientChatRoutes, { prefix: '/api/client/chat' })
return app return app
} }

View File

@@ -64,6 +64,84 @@ const migrate = async () => {
ON CONFLICT (key) DO NOTHING ON CONFLICT (key) DO NOTHING
` `
// --- Phase 2: Mitra Online Status & Pairing ---
await sql`
CREATE TABLE IF NOT EXISTS mitra_online_status (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mitra_id UUID NOT NULL UNIQUE REFERENCES mitras(id),
is_online BOOLEAN NOT NULL DEFAULT FALSE,
last_online_at TIMESTAMPTZ,
last_offline_at TIMESTAMPTZ,
last_heartbeat_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE TABLE IF NOT EXISTS mitra_online_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mitra_id UUID NOT NULL REFERENCES mitras(id),
status VARCHAR(10) NOT NULL,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_mitra_online_logs_mitra_id
ON mitra_online_logs (mitra_id)
`
await sql`
CREATE TABLE IF NOT EXISTS chat_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id),
mitra_id UUID REFERENCES mitras(id),
status VARCHAR(30) NOT NULL DEFAULT 'searching',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
paired_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
ended_by VARCHAR(20)
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_sessions_customer_id
ON chat_sessions (customer_id)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_sessions_mitra_id
ON chat_sessions (mitra_id)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_sessions_status
ON chat_sessions (status)
`
await sql`
CREATE TABLE IF NOT EXISTS chat_request_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES chat_sessions(id),
mitra_id UUID NOT NULL REFERENCES mitras(id),
notified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
response VARCHAR(20),
responded_at TIMESTAMPTZ
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_request_notifications_session_id
ON chat_request_notifications (session_id)
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('max_customers_per_mitra', '{"value": 3}')
ON CONFLICT (key) DO NOTHING
`
console.log('Migration complete.') console.log('Migration complete.')
await sql.end() await sql.end()
} }

View File

@@ -0,0 +1,47 @@
import Redis from 'ioredis'
let pub
let sub
let client
export const getValkeyClient = () => {
if (!client) {
const url = process.env.VALKEY_URL || 'redis://localhost:6379'
client = new Redis(url)
}
return client
}
export const getValkeyPub = () => {
if (!pub) {
const url = process.env.VALKEY_URL || 'redis://localhost:6379'
pub = new Redis(url)
}
return pub
}
export const getValkeySub = () => {
if (!sub) {
const url = process.env.VALKEY_URL || 'redis://localhost:6379'
sub = new Redis(url)
}
return sub
}
export const publish = async (channel, data) => {
const pubClient = getValkeyPub()
await pubClient.publish(channel, JSON.stringify(data))
}
export const subscribe = (channel, callback) => {
const subClient = getValkeySub()
subClient.subscribe(channel)
subClient.on('message', (ch, message) => {
if (ch === channel) {
callback(JSON.parse(message))
}
})
return () => {
subClient.unsubscribe(channel)
}
}

View File

@@ -1,6 +1,6 @@
import { authenticate, requirePermission } from '../../plugins/auth.js' import { authenticate, requirePermission } from '../../plugins/auth.js'
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js' import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
import { getAnonymityConfig, setAnonymityConfig } from '../../services/config.service.js' import { getAnonymityConfig, setAnonymityConfig, getMaxCustomersPerMitra, setMaxCustomersPerMitra } from '../../services/config.service.js'
const attachCcUser = async (request, reply) => { const attachCcUser = async (request, reply) => {
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
@@ -26,4 +26,22 @@ export const internalConfigRoutes = async (app) => {
const config = await setAnonymityConfig(anonymity_enabled) const config = await setAnonymityConfig(anonymity_enabled)
return reply.send({ success: true, data: config }) return reply.send({ success: true, data: config })
}) })
app.get('/max-customers-per-mitra', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (request, reply) => {
const config = await getMaxCustomersPerMitra()
return reply.send({ success: true, data: config })
})
app.patch('/max-customers-per-mitra', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { max_customers_per_mitra } = request.body ?? {}
if (typeof max_customers_per_mitra !== 'number' || max_customers_per_mitra < 1) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'max_customers_per_mitra must be a positive number' } })
}
const config = await setMaxCustomersPerMitra(max_customers_per_mitra)
return reply.send({ success: true, data: config })
})
} }

View File

@@ -1,6 +1,7 @@
import { authenticate, requirePermission } from '../../plugins/auth.js' import { authenticate, requirePermission } from '../../plugins/auth.js'
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js' import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
import { createMitra, listMitras, updateMitraStatus } from '../../services/mitra.service.js' import { createMitra, listMitras, updateMitraStatus } from '../../services/mitra.service.js'
import { getOnlineMitras, getOnlineLogs } from '../../services/mitra-status.service.js'
const attachCcUser = async (request, reply) => { const attachCcUser = async (request, reply) => {
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
@@ -42,4 +43,19 @@ export const mitraManagementRoutes = async (app) => {
const mitra = await updateMitraStatus(request.params.id, is_active) const mitra = await updateMitraStatus(request.params.id, is_active)
return reply.send({ success: true, data: mitra }) return reply.send({ success: true, data: mitra })
}) })
app.get('/online', {
preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'read')],
}, async (request, reply) => {
const mitras = await getOnlineMitras()
return reply.send({ success: true, data: mitras })
})
app.get('/:id/online-logs', {
preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'read')],
}, async (request, reply) => {
const { page = 1, limit = 50 } = request.query
const result = await getOnlineLogs(request.params.id, { page: Number(page), limit: Number(limit) })
return reply.send({ success: true, data: result })
})
} }

View File

@@ -0,0 +1,48 @@
import { authenticate, requirePermission } from '../../plugins/auth.js'
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
import { listSessions, getSessionById, rerouteSession } from '../../services/session.service.js'
import { getDashboardStats } from '../../services/dashboard.service.js'
const attachCcUser = async (request, reply) => {
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
request.ccUser = user
}
export const sessionManagementRoutes = async (app) => {
app.get('/dashboard/stats', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (request, reply) => {
const stats = await getDashboardStats()
return reply.send({ success: true, data: stats })
})
app.get('/', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (request, reply) => {
const { page = 1, limit = 20, status } = request.query
const result = await listSessions({ page: Number(page), limit: Number(limit), status })
return reply.send({ success: true, data: result })
})
app.get('/:sessionId', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (request, reply) => {
const session = await getSessionById(request.params.sessionId)
if (!session) {
return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Session not found' } })
}
return reply.send({ success: true, data: session })
})
app.post('/:sessionId/reroute', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { new_mitra_id } = request.body ?? {}
if (!new_mitra_id) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'new_mitra_id is required' } })
}
const session = await rerouteSession(request.params.sessionId, new_mitra_id)
return reply.send({ success: true, data: session })
})
}

View File

@@ -0,0 +1,75 @@
import { authenticate } from '../../plugins/auth.js'
import { getCustomerByFirebaseUid } from '../../services/customer.service.js'
import { createPairingRequest, cancelPairingRequest, getSessionStatus } from '../../services/pairing.service.js'
import { getActiveSessionByCustomer, endSession } from '../../services/session.service.js'
import { subscribe } from '../../plugins/valkey.js'
const resolveCustomer = async (request, reply) => {
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
if (!customer) {
return reply.code(404).send({
success: false,
error: { code: 'ACCOUNT_NOT_FOUND', message: 'Customer account not found' },
})
}
request.customer = customer
}
export const clientChatRoutes = async (app) => {
app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await createPairingRequest(request.customer.id)
return reply.code(201).send({ success: true, data: session })
})
app.get('/request/:sessionId/status', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { sessionId } = request.params
// SSE stream for real-time status updates
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
// Send current status immediately
const current = await getSessionStatus(sessionId)
if (current) {
reply.raw.write(`data: ${JSON.stringify(current)}\n\n`)
}
// If already in a terminal state, close
if (current && ['active', 'completed', 'cancelled', 'expired'].includes(current.status)) {
reply.raw.end()
return
}
// Subscribe to status updates
const unsubscribe = subscribe(`session:${sessionId}:status`, (data) => {
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`)
if (['paired', 'expired', 'session_ended'].includes(data.type)) {
reply.raw.end()
unsubscribe()
}
})
// Clean up on client disconnect
request.raw.on('close', () => {
unsubscribe()
})
})
app.post('/request/:sessionId/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await cancelPairingRequest(request.params.sessionId, request.customer.id)
return reply.send({ success: true, data: session })
})
app.get('/session/active', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await getActiveSessionByCustomer(request.customer.id)
return reply.send({ success: true, data: session ?? null })
})
app.post('/session/:sessionId/end', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await endSession(request.params.sessionId, 'customer')
return reply.send({ success: true, data: session })
})
}

View File

@@ -0,0 +1,69 @@
import { authenticate } from '../../plugins/auth.js'
import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
import { acceptPairingRequest, declinePairingRequest } from '../../services/pairing.service.js'
import { getActiveSessionsByMitra, endSession } from '../../services/session.service.js'
import { subscribe } from '../../plugins/valkey.js'
const resolveMitra = async (request, reply) => {
const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid)
if (!mitra) {
return reply.code(404).send({
success: false,
error: { code: 'ACCOUNT_NOT_FOUND', message: 'Mitra account not found' },
})
}
if (!mitra.is_active) {
return reply.code(403).send({
success: false,
error: { code: 'ACCOUNT_INACTIVE', message: 'Account is inactive' },
})
}
request.mitra = mitra
}
export const mitraChatRoutes = async (app) => {
app.get('/incoming', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
const mitraId = request.mitra.id
// SSE stream for incoming chat requests
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
// Keep-alive ping
const pingInterval = setInterval(() => {
reply.raw.write(': ping\n\n')
}, 15_000)
const unsubscribe = subscribe(`mitra:${mitraId}:requests`, (data) => {
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`)
})
request.raw.on('close', () => {
clearInterval(pingInterval)
unsubscribe()
})
})
app.post('/:sessionId/accept', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
const session = await acceptPairingRequest(request.params.sessionId, request.mitra.id)
return reply.send({ success: true, data: session })
})
app.post('/:sessionId/decline', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
await declinePairingRequest(request.params.sessionId, request.mitra.id)
return reply.send({ success: true })
})
app.get('/sessions/active', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
const sessions = await getActiveSessionsByMitra(request.mitra.id)
return reply.send({ success: true, data: sessions })
})
app.post('/sessions/:sessionId/end', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
const session = await endSession(request.params.sessionId, 'mitra')
return reply.send({ success: true, data: session })
})
}

View File

@@ -0,0 +1,43 @@
import { authenticate } from '../../plugins/auth.js'
import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
import * as mitraStatusService from '../../services/mitra-status.service.js'
export const mitraStatusRoutes = async (app) => {
// Resolve mitra from Firebase token
const resolveMitra = async (request, reply) => {
const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid)
if (!mitra) {
return reply.code(404).send({
success: false,
error: { code: 'ACCOUNT_NOT_FOUND', message: 'Mitra account not found' },
})
}
if (!mitra.is_active) {
return reply.code(403).send({
success: false,
error: { code: 'ACCOUNT_INACTIVE', message: 'Account is inactive' },
})
}
request.mitra = mitra
}
app.post('/online', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
await mitraStatusService.setOnline(request.mitra.id)
return reply.send({ success: true, data: { is_online: true } })
})
app.post('/offline', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
await mitraStatusService.setOffline(request.mitra.id)
return reply.send({ success: true, data: { is_online: false } })
})
app.post('/heartbeat', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
await mitraStatusService.heartbeat(request.mitra.id)
return reply.send({ success: true })
})
app.get('/', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
const status = await mitraStatusService.getStatus(request.mitra.id)
return reply.send({ success: true, data: status })
})
}

View File

@@ -1,6 +1,7 @@
import 'dotenv/config' import 'dotenv/config'
import { buildPublicApp } from './app.public.js' import { buildPublicApp } from './app.public.js'
import { buildInternalApp } from './app.internal.js' import { buildInternalApp } from './app.internal.js'
import { autoOfflineStaleMitras } from './services/mitra-status.service.js'
const PUBLIC_PORT = process.env.PUBLIC_PORT || 3000 const PUBLIC_PORT = process.env.PUBLIC_PORT || 3000
const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001 const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001
@@ -15,6 +16,16 @@ const start = async () => {
await internalApp.listen({ port: INTERNAL_PORT, host: INTERNAL_HOST }) await internalApp.listen({ port: INTERNAL_PORT, host: INTERNAL_HOST })
console.log(`Internal API listening on ${INTERNAL_HOST}:${INTERNAL_PORT}`) console.log(`Internal API listening on ${INTERNAL_HOST}:${INTERNAL_PORT}`)
// Auto-offline mitras with stale heartbeat (every 30s)
setInterval(async () => {
try {
const count = await autoOfflineStaleMitras(45)
if (count > 0) console.log(`Auto-offlined ${count} stale mitra(s)`)
} catch (err) {
console.error('Auto-offline check failed:', err)
}
}, 30_000)
} }
start().catch((err) => { start().catch((err) => {

View File

@@ -15,3 +15,17 @@ export const setAnonymityConfig = async (enabled) => {
` `
return { anonymity_enabled: enabled } return { anonymity_enabled: enabled }
} }
export const getMaxCustomersPerMitra = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'max_customers_per_mitra'`
return { max_customers_per_mitra: row?.value?.value ?? 3 }
}
export const setMaxCustomersPerMitra = async (value) => {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('max_customers_per_mitra', ${sql.json({ value })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
return { max_customers_per_mitra: value }
}

View File

@@ -0,0 +1,28 @@
import { getDb } from '../db/client.js'
const sql = getDb()
export const getDashboardStats = async () => {
const [[{ active_chats }], [{ online_mitras }], [{ pending_requests }]] = await Promise.all([
sql`SELECT COUNT(*) AS active_chats FROM chat_sessions WHERE status IN ('active', 'pending_payment')`,
sql`SELECT COUNT(*) AS online_mitras FROM mitra_online_status WHERE is_online = true`,
sql`SELECT COUNT(*) AS pending_requests FROM chat_sessions WHERE status IN ('searching', 'pending_acceptance')`,
])
const customersPerMitra = await sql`
SELECT m.id, m.display_name,
(SELECT COUNT(*) FROM chat_sessions cs
WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment')) AS active_session_count
FROM mitras m
INNER JOIN mitra_online_status s ON s.mitra_id = m.id
WHERE s.is_online = true
ORDER BY active_session_count DESC
`
return {
active_chats: Number(active_chats),
online_mitras: Number(online_mitras),
pending_requests: Number(pending_requests),
customers_per_mitra: customersPerMitra,
}
}

View File

@@ -0,0 +1,107 @@
import { getDb } from '../db/client.js'
const sql = getDb()
export const ensureStatusRow = async (mitraId) => {
await sql`
INSERT INTO mitra_online_status (mitra_id)
VALUES (${mitraId})
ON CONFLICT (mitra_id) DO NOTHING
`
}
export const setOnline = async (mitraId) => {
await ensureStatusRow(mitraId)
const now = new Date()
await sql`
UPDATE mitra_online_status
SET is_online = true, last_online_at = ${now}, last_heartbeat_at = ${now}, updated_at = ${now}
WHERE mitra_id = ${mitraId}
`
await sql`
INSERT INTO mitra_online_logs (mitra_id, status) VALUES (${mitraId}, 'online')
`
}
export const setOffline = async (mitraId) => {
await ensureStatusRow(mitraId)
const now = new Date()
const [status] = await sql`
SELECT is_online FROM mitra_online_status WHERE mitra_id = ${mitraId}
`
if (!status?.is_online) return
await sql`
UPDATE mitra_online_status
SET is_online = false, last_offline_at = ${now}, updated_at = ${now}
WHERE mitra_id = ${mitraId}
`
await sql`
INSERT INTO mitra_online_logs (mitra_id, status) VALUES (${mitraId}, 'offline')
`
}
export const heartbeat = async (mitraId) => {
const now = new Date()
await sql`
UPDATE mitra_online_status
SET last_heartbeat_at = ${now}, updated_at = ${now}
WHERE mitra_id = ${mitraId} AND is_online = true
`
}
export const getStatus = async (mitraId) => {
await ensureStatusRow(mitraId)
const [status] = await sql`
SELECT is_online, last_online_at, last_offline_at, updated_at
FROM mitra_online_status
WHERE mitra_id = ${mitraId}
`
return status
}
export const getOnlineMitras = async () => {
const mitras = await sql`
SELECT m.id, m.display_name, m.phone, s.last_online_at, s.updated_at,
(SELECT COUNT(*) FROM chat_sessions cs
WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment')) AS active_session_count
FROM mitras m
INNER JOIN mitra_online_status s ON s.mitra_id = m.id
WHERE s.is_online = true AND m.is_active = true
ORDER BY s.last_online_at DESC
`
return mitras
}
export const getOnlineLogs = async (mitraId, { page = 1, limit = 50 } = {}) => {
const offset = (page - 1) * limit
const items = await sql`
SELECT id, status, timestamp
FROM mitra_online_logs
WHERE mitra_id = ${mitraId}
ORDER BY timestamp DESC
LIMIT ${limit} OFFSET ${offset}
`
const [{ count }] = await sql`
SELECT COUNT(*) FROM mitra_online_logs WHERE mitra_id = ${mitraId}
`
return { items, total: Number(count), page, limit }
}
export const autoOfflineStaleMitras = async (staleSeconds = 45) => {
const stale = await sql`
UPDATE mitra_online_status
SET is_online = false, last_offline_at = NOW(), updated_at = NOW()
WHERE is_online = true
AND last_heartbeat_at < NOW() - ${staleSeconds + ' seconds'}::interval
RETURNING mitra_id
`
for (const row of stale) {
await sql`
INSERT INTO mitra_online_logs (mitra_id, status) VALUES (${row.mitra_id}, 'offline')
`
}
return stale.length
}

View File

@@ -0,0 +1,247 @@
import { getDb } from '../db/client.js'
import { getMaxCustomersPerMitra } from './config.service.js'
import { publish } from '../plugins/valkey.js'
const sql = getDb()
// Timeout map for active pairing requests (sessionId → timeoutId)
const pairingTimeouts = new Map()
export const findAvailableMitras = async () => {
const { max_customers_per_mitra } = await getMaxCustomersPerMitra()
const mitras = await sql`
SELECT m.id, m.display_name
FROM mitras m
INNER JOIN mitra_online_status s ON s.mitra_id = m.id
WHERE m.is_active = true
AND s.is_online = true
AND (
SELECT COUNT(*) FROM chat_sessions cs
WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment')
) < ${max_customers_per_mitra}
`
return mitras
}
export const createPairingRequest = async (customerId) => {
// Check for existing active session or request
const [existing] = await sql`
SELECT id, status FROM chat_sessions
WHERE customer_id = ${customerId}
AND status IN ('searching', 'pending_acceptance', 'pending_payment', 'active')
`
if (existing) {
throw Object.assign(new Error('Customer already has an active session or request'), {
code: 'ALREADY_ACTIVE', statusCode: 409,
})
}
const availableMitras = await findAvailableMitras()
if (availableMitras.length === 0) {
throw Object.assign(new Error('No bestie available'), {
code: 'NO_MITRA_AVAILABLE', statusCode: 404,
})
}
// Create session
const [session] = await sql`
INSERT INTO chat_sessions (customer_id, status)
VALUES (${customerId}, 'pending_acceptance')
RETURNING id, customer_id, status, created_at
`
// Create notifications for all available mitras
for (const mitra of availableMitras) {
await sql`
INSERT INTO chat_request_notifications (session_id, mitra_id)
VALUES (${session.id}, ${mitra.id})
`
// Publish to mitra's channel
await publish(`mitra:${mitra.id}:requests`, {
type: 'chat_request',
session_id: session.id,
created_at: session.created_at,
})
}
// Start 60s timeout
const timeoutId = setTimeout(async () => {
try {
await expirePairingRequest(session.id)
} catch (_) {}
}, 60_000)
pairingTimeouts.set(session.id, timeoutId)
return session
}
export const acceptPairingRequest = async (sessionId, mitraId) => {
// Use a transaction-like approach: update only if status is still pending_acceptance
const [session] = await sql`
UPDATE chat_sessions
SET mitra_id = ${mitraId}, status = 'pending_payment', paired_at = NOW()
WHERE id = ${sessionId} AND status = 'pending_acceptance' AND mitra_id IS NULL
RETURNING id, customer_id, mitra_id, status, paired_at
`
if (!session) {
throw Object.assign(new Error('Request already accepted or expired'), {
code: 'REQUEST_UNAVAILABLE', statusCode: 409,
})
}
// Mark this mitra's notification as accepted
await sql`
UPDATE chat_request_notifications
SET response = 'accepted', responded_at = NOW()
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId}
`
// Mark other mitras' notifications as ignored
await sql`
UPDATE chat_request_notifications
SET response = 'ignored', responded_at = NOW()
WHERE session_id = ${sessionId} AND mitra_id != ${mitraId} AND response IS NULL
`
// Clear timeout
const timeoutId = pairingTimeouts.get(sessionId)
if (timeoutId) {
clearTimeout(timeoutId)
pairingTimeouts.delete(sessionId)
}
// Auto-skip payment for now: move to active
const [activeSession] = await sql`
UPDATE chat_sessions SET status = 'active'
WHERE id = ${sessionId}
RETURNING id, customer_id, mitra_id, status, paired_at
`
// Get mitra display name for customer notification
const [mitra] = await sql`
SELECT display_name FROM mitras WHERE id = ${mitraId}
`
// Notify customer
await publish(`session:${sessionId}:status`, {
type: 'paired',
session_id: sessionId,
mitra_display_name: mitra.display_name,
status: 'active',
})
// Notify other mitras to dismiss the request
const notifications = await sql`
SELECT mitra_id FROM chat_request_notifications
WHERE session_id = ${sessionId} AND mitra_id != ${mitraId}
`
for (const n of notifications) {
await publish(`mitra:${n.mitra_id}:requests`, {
type: 'chat_request_closed',
session_id: sessionId,
})
}
return activeSession
}
export const declinePairingRequest = async (sessionId, mitraId) => {
await sql`
UPDATE chat_request_notifications
SET response = 'declined', responded_at = NOW()
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} AND response IS NULL
`
}
export const cancelPairingRequest = async (sessionId, customerId) => {
const [session] = await sql`
UPDATE chat_sessions
SET status = 'cancelled'
WHERE id = ${sessionId} AND customer_id = ${customerId}
AND status IN ('searching', 'pending_acceptance')
RETURNING id, status
`
if (!session) {
throw Object.assign(new Error('Cannot cancel this request'), {
code: 'CANNOT_CANCEL', statusCode: 409,
})
}
// Clear timeout
const timeoutId = pairingTimeouts.get(sessionId)
if (timeoutId) {
clearTimeout(timeoutId)
pairingTimeouts.delete(sessionId)
}
// Mark all notifications as ignored
await sql`
UPDATE chat_request_notifications
SET response = 'ignored', responded_at = NOW()
WHERE session_id = ${sessionId} AND response IS NULL
`
// Notify mitras to dismiss
const notifications = await sql`
SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId}
`
for (const n of notifications) {
await publish(`mitra:${n.mitra_id}:requests`, {
type: 'chat_request_closed',
session_id: sessionId,
})
}
return session
}
export const expirePairingRequest = async (sessionId) => {
const [session] = await sql`
UPDATE chat_sessions
SET status = 'expired'
WHERE id = ${sessionId} AND status = 'pending_acceptance'
RETURNING id, customer_id, status
`
if (!session) return null
pairingTimeouts.delete(sessionId)
// Mark all pending notifications as ignored
await sql`
UPDATE chat_request_notifications
SET response = 'ignored', responded_at = NOW()
WHERE session_id = ${sessionId} AND response IS NULL
`
// Notify customer
await publish(`session:${sessionId}:status`, {
type: 'expired',
session_id: sessionId,
})
// Notify mitras to dismiss
const notifications = await sql`
SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId}
`
for (const n of notifications) {
await publish(`mitra:${n.mitra_id}:requests`, {
type: 'chat_request_closed',
session_id: sessionId,
})
}
return session
}
export const getSessionStatus = async (sessionId) => {
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
m.display_name AS mitra_display_name
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id
WHERE cs.id = ${sessionId}
`
return session
}

View File

@@ -0,0 +1,149 @@
import { getDb } from '../db/client.js'
import { publish } from '../plugins/valkey.js'
const sql = getDb()
export const getActiveSessionByCustomer = async (customerId) => {
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at,
m.display_name AS mitra_display_name
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id
WHERE cs.customer_id = ${customerId}
AND cs.status IN ('active', 'pending_payment')
ORDER BY cs.created_at DESC LIMIT 1
`
return session
}
export const getActiveSessionsByMitra = async (mitraId) => {
const sessions = await sql`
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at,
c.display_name AS customer_display_name
FROM chat_sessions cs
INNER JOIN customers c ON c.id = cs.customer_id
WHERE cs.mitra_id = ${mitraId}
AND cs.status IN ('active', 'pending_payment')
ORDER BY cs.created_at DESC
`
return sessions
}
export const endSession = async (sessionId, endedBy) => {
const [session] = await sql`
UPDATE chat_sessions
SET status = 'completed', ended_at = NOW(), ended_by = ${endedBy}
WHERE id = ${sessionId} AND status IN ('active', 'pending_payment')
RETURNING id, customer_id, mitra_id, status, ended_at, ended_by
`
if (!session) {
throw Object.assign(new Error('Session not found or already ended'), {
code: 'SESSION_NOT_ACTIVE', statusCode: 409,
})
}
// Notify both parties
await publish(`session:${sessionId}:status`, {
type: 'session_ended',
session_id: sessionId,
ended_by: endedBy,
})
return session
}
export const rerouteSession = async (sessionId, newMitraId) => {
// Get current session
const [current] = await sql`
SELECT id, customer_id, mitra_id, status FROM chat_sessions
WHERE id = ${sessionId} AND status IN ('active', 'pending_payment')
`
if (!current) {
throw Object.assign(new Error('Session not found or not active'), {
code: 'SESSION_NOT_ACTIVE', statusCode: 409,
})
}
// Verify new mitra is online
const [newMitraStatus] = await sql`
SELECT is_online FROM mitra_online_status WHERE mitra_id = ${newMitraId}
`
if (!newMitraStatus?.is_online) {
throw Object.assign(new Error('Target mitra is not online'), {
code: 'MITRA_NOT_ONLINE', statusCode: 422,
})
}
const oldMitraId = current.mitra_id
// Update session with new mitra (forced assignment)
const [session] = await sql`
UPDATE chat_sessions
SET mitra_id = ${newMitraId}
WHERE id = ${sessionId}
RETURNING id, customer_id, mitra_id, status
`
const [newMitra] = await sql`
SELECT display_name FROM mitras WHERE id = ${newMitraId}
`
// Notify customer about reroute
await publish(`session:${sessionId}:status`, {
type: 'rerouted',
session_id: sessionId,
mitra_display_name: newMitra.display_name,
})
// Notify old mitra session removed
if (oldMitraId) {
await publish(`mitra:${oldMitraId}:requests`, {
type: 'session_rerouted',
session_id: sessionId,
})
}
// Notify new mitra about new session
await publish(`mitra:${newMitraId}:requests`, {
type: 'session_assigned',
session_id: sessionId,
})
return session
}
export const listSessions = async ({ page = 1, limit = 20, status } = {}) => {
const offset = (page - 1) * limit
const conditions = status
? sql`WHERE cs.status = ${status}`
: sql``
const items = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
c.display_name AS customer_display_name,
m.display_name AS mitra_display_name
FROM chat_sessions cs
INNER JOIN customers c ON c.id = cs.customer_id
LEFT JOIN mitras m ON m.id = cs.mitra_id
${conditions}
ORDER BY cs.created_at DESC
LIMIT ${limit} OFFSET ${offset}
`
const [{ count }] = await sql`SELECT COUNT(*) FROM chat_sessions cs ${conditions}`
return { items, total: Number(count), page, limit }
}
export const getSessionById = async (sessionId) => {
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
c.display_name AS customer_display_name,
m.display_name AS mitra_display_name
FROM chat_sessions cs
INNER JOIN customers c ON c.id = cs.customer_id
LEFT JOIN mitras m ON m.id = cs.mitra_id
WHERE cs.id = ${sessionId}
`
return session
}

View File

@@ -2,7 +2,7 @@ import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
class ApiClient { class ApiClient {
static const String _baseUrl = String.fromEnvironment( static const String baseUrl = String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: 'https://api.halobestie.com', defaultValue: 'https://api.halobestie.com',
); );
@@ -10,7 +10,7 @@ class ApiClient {
late final Dio _dio; late final Dio _dio;
ApiClient() { ApiClient() {
_dio = Dio(BaseOptions(baseUrl: _baseUrl)); _dio = Dio(BaseOptions(baseUrl: baseUrl));
_dio.interceptors.add(InterceptorsWrapper( _dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async { onRequest: (options, handler) async {
final user = FirebaseAuth.instance.currentUser; final user = FirebaseAuth.instance.currentUser;

View File

@@ -0,0 +1,173 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../api/api_client.dart';
// Events
abstract class PairingEvent extends Equatable {
@override
List<Object?> get props => [];
}
class RequestPairing extends PairingEvent {}
class CancelPairing extends PairingEvent {}
class _PairingStatusUpdate extends PairingEvent {
final Map<String, dynamic> data;
_PairingStatusUpdate(this.data);
@override
List<Object?> get props => [data];
}
class _PairingTimeout extends PairingEvent {}
// States
abstract class PairingState extends Equatable {
@override
List<Object?> get props => [];
}
class PairingInitial extends PairingState {}
class PairingSearching extends PairingState {
final String sessionId;
PairingSearching(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
class PairingBestieFound extends PairingState {
final String sessionId;
final String mitraName;
PairingBestieFound({required this.sessionId, required this.mitraName});
@override
List<Object?> get props => [sessionId, mitraName];
}
class PairingActive extends PairingState {
final String sessionId;
final String mitraName;
PairingActive({required this.sessionId, required this.mitraName});
@override
List<Object?> get props => [sessionId, mitraName];
}
class PairingNoBestie extends PairingState {}
class PairingCancelled extends PairingState {}
class PairingError extends PairingState {
final String message;
PairingError(this.message);
@override
List<Object?> get props => [message];
}
// Bloc
class PairingBloc extends Bloc<PairingEvent, PairingState> {
final ApiClient apiClient;
Timer? _timeoutTimer;
StreamSubscription? _sseSubscription;
PairingBloc({required this.apiClient}) : super(PairingInitial()) {
on<RequestPairing>(_onRequestPairing);
on<CancelPairing>(_onCancelPairing);
on<_PairingStatusUpdate>(_onStatusUpdate);
on<_PairingTimeout>(_onTimeout);
}
Future<void> _onRequestPairing(RequestPairing event, Emitter<PairingState> emit) async {
try {
final response = await apiClient.post('/api/client/chat/request');
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'];
if (code == 'NO_MITRA_AVAILABLE') {
emit(PairingNoBestie());
} else if (code == 'ALREADY_ACTIVE') {
emit(PairingError('Kamu sudah memiliki sesi aktif.'));
} else {
emit(PairingError('Gagal memulai. Coba lagi.'));
}
}
}
void _listenToSSE(String sessionId) {
final dio = Dio(BaseOptions(baseUrl: ApiClient.baseUrl));
// SSE endpoint — use responseType stream
dio.get(
'/api/client/chat/request/$sessionId/status',
options: Options(responseType: ResponseType.stream),
).then((response) {
final stream = response.data.stream as Stream<List<int>>;
_sseSubscription = stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.where((line) => line.startsWith('data: '))
.map((line) => jsonDecode(line.substring(6)) as Map<String, dynamic>)
.listen(
(data) => add(_PairingStatusUpdate(data)),
onError: (_) {},
);
}).catchError((_) {});
}
Future<void> _onStatusUpdate(_PairingStatusUpdate event, Emitter<PairingState> emit) async {
final data = event.data;
final type = data['type'] as String?;
if (type == 'paired') {
_cleanup();
final mitraName = data['mitra_display_name'] as String? ?? 'Bestie';
final sessionId = data['session_id'] as String;
emit(PairingBestieFound(sessionId: sessionId, mitraName: mitraName));
// Brief delay then transition to active
await Future.delayed(const Duration(seconds: 2));
emit(PairingActive(sessionId: sessionId, mitraName: mitraName));
} else if (type == 'expired') {
_cleanup();
emit(PairingNoBestie());
}
}
Future<void> _onCancelPairing(CancelPairing event, Emitter<PairingState> emit) async {
if (state is PairingSearching) {
final sessionId = (state as PairingSearching).sessionId;
try {
await apiClient.post('/api/client/chat/request/$sessionId/cancel');
} catch (_) {}
_cleanup();
emit(PairingCancelled());
}
}
Future<void> _onTimeout(_PairingTimeout event, Emitter<PairingState> emit) async {
_cleanup();
emit(PairingNoBestie());
}
void _cleanup() {
_timeoutTimer?.cancel();
_timeoutTimer = null;
_sseSubscription?.cancel();
_sseSubscription = null;
}
@override
Future<void> close() {
_cleanup();
return super.close();
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/pairing/pairing_bloc.dart';
class BestieFoundScreen extends StatelessWidget {
final String sessionId;
final String mitraName;
const BestieFoundScreen({
super.key,
required this.sessionId,
required this.mitraName,
});
@override
Widget build(BuildContext context) {
return BlocListener<PairingBloc, PairingState>(
listener: (context, state) {
if (state is PairingActive) {
context.go('/chat/session/${state.sessionId}', extra: state.mitraName);
}
},
child: Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.check_circle, size: 80, color: Colors.green),
const SizedBox(height: 24),
const Text(
'Bestie ditemukan!',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Menghubungkan kamu ke $mitraName',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 24),
const CircularProgressIndicator(),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class NoBestieScreen extends StatelessWidget {
const NoBestieScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.sentiment_dissatisfied, size: 80, color: Colors.orange),
const SizedBox(height: 24),
const Text(
'Bestie belum tersedia',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Maaf, semua Bestie sedang sibuk. Coba lagi nanti ya.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 48),
ElevatedButton(
onPressed: () => context.go('/home'),
child: const Text('Kembali'),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/pairing/pairing_bloc.dart';
class SearchingScreen extends StatelessWidget {
const SearchingScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocListener<PairingBloc, PairingState>(
listener: (context, state) {
if (state is PairingBestieFound) {
context.go('/chat/found', extra: {
'sessionId': state.sessionId,
'mitraName': state.mitraName,
});
} else if (state is PairingNoBestie) {
context.go('/chat/no-bestie');
} else if (state is PairingCancelled) {
context.go('/home');
}
},
child: Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 32),
const Text(
'Mencari Bestie...',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Tunggu sebentar ya, kami sedang mencarikan Bestie untukmu',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 48),
OutlinedButton(
onPressed: () => context.read<PairingBloc>().add(CancelPairing()),
child: const Text('Batalkan'),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_client.dart';
class SessionActiveScreen extends StatelessWidget {
final String sessionId;
final String mitraName;
const SessionActiveScreen({
super.key,
required this.sessionId,
required this.mitraName,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Sesi Aktif'),
automaticallyImplyLeading: false,
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.chat_bubble, size: 80, color: Colors.blue),
const SizedBox(height: 24),
Text(
'Terhubung dengan $mitraName',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Sesi chat akan tersedia di fase berikutnya.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 48),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => _endSession(context),
child: const Text('Akhiri Sesi', style: TextStyle(color: Colors.white)),
),
],
),
),
),
);
}
Future<void> _endSession(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Akhiri Sesi?'),
content: const Text('Apakah kamu yakin ingin mengakhiri sesi ini?'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Batal')),
TextButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Ya, Akhiri')),
],
),
);
if (confirmed == true && context.mounted) {
try {
final apiClient = context.read<ApiClient>();
await apiClient.post('/api/client/chat/session/$sessionId/end');
if (context.mounted) context.go('/home');
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Gagal mengakhiri sesi. Coba lagi.')),
);
}
}
}
}
}

View File

@@ -1,14 +1,27 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth/auth_bloc.dart'; import '../../core/auth/auth_bloc.dart';
import '../../core/pairing/pairing_bloc.dart';
/// Phase 1 placeholder — will be replaced in Phase 2 with chat/session features.
class HomeScreen extends StatelessWidget { class HomeScreen extends StatelessWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<AuthBloc, AuthState>( return BlocListener<PairingBloc, PairingState>(
listener: (context, state) {
if (state is PairingSearching) {
context.go('/chat/searching');
} else if (state is PairingNoBestie) {
context.go('/chat/no-bestie');
} else if (state is PairingError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) { builder: (context, state) {
final displayName = state is AuthAuthenticated final displayName = state is AuthAuthenticated
? state.profile['display_name'] as String ? state.profile['display_name'] as String
@@ -27,13 +40,27 @@ class HomeScreen extends StatelessWidget {
], ],
), ),
body: Center( body: Center(
child: Text( child: Padding(
'Halo, $displayName!', padding: const EdgeInsets.all(32),
style: const TextStyle(fontSize: 24), child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
const SizedBox(height: 48),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
),
onPressed: () => context.read<PairingBloc>().add(RequestPairing()),
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
),
],
),
), ),
), ),
); );
}, },
),
); );
} }
} }

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/api/api_client.dart'; import 'core/api/api_client.dart';
import 'core/auth/auth_bloc.dart'; import 'core/auth/auth_bloc.dart';
import 'core/pairing/pairing_bloc.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'router.dart'; import 'router.dart';
@@ -17,8 +18,13 @@ class App extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( final apiClient = ApiClient();
create: (_) => AuthBloc(apiClient: ApiClient())..add(AppStarted()), return MultiBlocProvider(
providers: [
BlocProvider(create: (_) => AuthBloc(apiClient: apiClient)..add(AppStarted())),
BlocProvider(create: (_) => PairingBloc(apiClient: apiClient)),
RepositoryProvider.value(value: apiClient),
],
child: BlocBuilder<AuthBloc, AuthState>( child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) { builder: (context, state) {
return MaterialApp.router( return MaterialApp.router(

View File

@@ -7,6 +7,10 @@ import 'features/auth/screens/register_screen.dart';
import 'features/auth/screens/otp_screen.dart'; import 'features/auth/screens/otp_screen.dart';
import 'features/auth/screens/force_register_screen.dart'; import 'features/auth/screens/force_register_screen.dart';
import 'features/home/home_screen.dart'; import 'features/home/home_screen.dart';
import 'features/chat/screens/searching_screen.dart';
import 'features/chat/screens/bestie_found_screen.dart';
import 'features/chat/screens/no_bestie_screen.dart';
import 'features/chat/screens/session_active_screen.dart';
GoRouter buildRouter(AuthBloc authBloc) { GoRouter buildRouter(AuthBloc authBloc) {
return GoRouter( return GoRouter(
@@ -30,6 +34,21 @@ GoRouter buildRouter(AuthBloc authBloc) {
GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)), GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()), GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/chat/searching', builder: (_, __) => const SearchingScreen()),
GoRoute(path: '/chat/found', builder: (context, state) {
final extra = state.extra as Map<String, dynamic>;
return BestieFoundScreen(
sessionId: extra['sessionId'] as String,
mitraName: extra['mitraName'] as String,
);
}),
GoRoute(path: '/chat/no-bestie', builder: (_, __) => const NoBestieScreen()),
GoRoute(path: '/chat/session/:sessionId', builder: (context, state) {
return SessionActiveScreen(
sessionId: state.pathParameters['sessionId']!,
mitraName: state.extra as String? ?? 'Bestie',
);
}),
], ],
); );
} }

View File

@@ -1,7 +1,9 @@
import { Routes, Route, Navigate } from 'react-router-dom' import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuth } from './core/auth/AuthContext' import { useAuth } from './core/auth/AuthContext'
import LoginPage from './pages/login/LoginPage' import LoginPage from './pages/login/LoginPage'
import DashboardPage from './pages/dashboard/DashboardPage'
import MitrasPage from './pages/mitras/MitrasPage' import MitrasPage from './pages/mitras/MitrasPage'
import SessionsPage from './pages/sessions/SessionsPage'
import UsersPage from './pages/users/UsersPage' import UsersPage from './pages/users/UsersPage'
import SettingsPage from './pages/settings/SettingsPage' import SettingsPage from './pages/settings/SettingsPage'
import Layout from './components/Layout' import Layout from './components/Layout'
@@ -17,8 +19,10 @@ export default function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}> <Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route index element={<Navigate to="/mitras" replace />} /> <Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="mitras" element={<MitrasPage />} /> <Route path="mitras" element={<MitrasPage />} />
<Route path="sessions" element={<SessionsPage />} />
<Route path="users" element={<UsersPage />} /> <Route path="users" element={<UsersPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
</Route> </Route>

View File

@@ -9,7 +9,9 @@ export default function Layout() {
<nav style={{ width: 220, borderRight: '1px solid #eee', padding: 16 }}> <nav style={{ width: 220, borderRight: '1px solid #eee', padding: 16 }}>
<h2>Control Center</h2> <h2>Control Center</h2>
<ul style={{ listStyle: 'none', padding: 0 }}> <ul style={{ listStyle: 'none', padding: 0 }}>
<li><NavLink to="/dashboard">Dashboard</NavLink></li>
<li><NavLink to="/mitras">Mitra</NavLink></li> <li><NavLink to="/mitras">Mitra</NavLink></li>
<li><NavLink to="/sessions">Sesi</NavLink></li>
<li><NavLink to="/users">Users</NavLink></li> <li><NavLink to="/users">Users</NavLink></li>
<li><NavLink to="/settings">Settings</NavLink></li> <li><NavLink to="/settings">Settings</NavLink></li>
</ul> </ul>

View File

@@ -0,0 +1,60 @@
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
const fetchDashboardStats = async () => {
const res = await apiClient.get('/internal/sessions/dashboard/stats')
return res.data.data
}
export default function DashboardPage() {
const { data, isLoading } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: fetchDashboardStats,
refetchInterval: 10000,
})
if (isLoading) return <div>Loading...</div>
return (
<div>
<h1>Dashboard</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 32 }}>
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8 }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#2563eb' }}>{data?.active_chats ?? 0}</div>
<div style={{ color: '#666' }}>Chat Aktif</div>
</div>
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8 }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#16a34a' }}>{data?.online_mitras ?? 0}</div>
<div style={{ color: '#666' }}>Mitra Online</div>
</div>
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8 }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#f59e0b' }}>{data?.pending_requests ?? 0}</div>
<div style={{ color: '#666' }}>Request Pending</div>
</div>
</div>
<h2>Customer per Mitra</h2>
{data?.customers_per_mitra?.length > 0 ? (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Mitra</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Sesi Aktif</th>
</tr>
</thead>
<tbody>
{data.customers_per_mitra.map((m) => (
<tr key={m.id}>
<td style={{ padding: 8 }}>{m.display_name}</td>
<td style={{ padding: 8 }}>{m.active_session_count}</td>
</tr>
))}
</tbody>
</table>
) : (
<p style={{ color: '#666' }}>Tidak ada mitra online.</p>
)}
</div>
)
}

View File

@@ -7,6 +7,11 @@ const fetchMitras = async () => {
return res.data.data return res.data.data
} }
const fetchOnlineMitras = async () => {
const res = await apiClient.get('/internal/mitras/online')
return res.data.data
}
const createMitra = async (data) => { const createMitra = async (data) => {
const res = await apiClient.post('/internal/mitras', data) const res = await apiClient.post('/internal/mitras', data)
return res.data.data return res.data.data
@@ -17,12 +22,29 @@ const updateMitraStatus = async ({ id, is_active }) => {
return res.data.data return res.data.data
} }
const fetchOnlineLogs = async (mitraId) => {
const res = await apiClient.get(`/internal/mitras/${mitraId}/online-logs`)
return res.data.data
}
export default function MitrasPage() { export default function MitrasPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['mitras'], queryFn: fetchMitras }) const { data, isLoading } = useQuery({ queryKey: ['mitras'], queryFn: fetchMitras })
const { data: onlineData } = useQuery({
queryKey: ['mitras-online'],
queryFn: fetchOnlineMitras,
refetchInterval: 10000,
})
const [form, setForm] = useState({ phone: '', display_name: '' }) const [form, setForm] = useState({ phone: '', display_name: '' })
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const [logsForMitra, setLogsForMitra] = useState(null)
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['mitra-online-logs', logsForMitra],
queryFn: () => fetchOnlineLogs(logsForMitra),
enabled: !!logsForMitra,
})
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: createMitra, mutationFn: createMitra,
@@ -40,6 +62,12 @@ export default function MitrasPage() {
if (isLoading) return <div>Loading...</div> if (isLoading) return <div>Loading...</div>
// Build a set of online mitra IDs for quick lookup
const onlineMitraMap = new Map()
for (const m of onlineData ?? []) {
onlineMitraMap.set(m.id, m)
}
return ( return (
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@@ -69,25 +97,69 @@ export default function MitrasPage() {
<tr> <tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nama</th> <th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nama</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nomor HP</th> <th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nomor HP</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status</th> <th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status Akun</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Online</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Sesi Aktif</th>
<th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th> <th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data?.items?.map((mitra) => ( {data?.items?.map((mitra) => {
const onlineInfo = onlineMitraMap.get(mitra.id)
return (
<tr key={mitra.id}> <tr key={mitra.id}>
<td style={{ padding: 8 }}>{mitra.display_name}</td> <td style={{ padding: 8 }}>{mitra.display_name}</td>
<td style={{ padding: 8 }}>{mitra.phone}</td> <td style={{ padding: 8 }}>{mitra.phone}</td>
<td style={{ padding: 8 }}>{mitra.is_active ? 'Aktif' : 'Nonaktif'}</td> <td style={{ padding: 8 }}>{mitra.is_active ? 'Aktif' : 'Nonaktif'}</td>
<td style={{ padding: 8 }}> <td style={{ padding: 8 }}>
<span style={{ color: onlineInfo ? 'green' : 'grey' }}>
{onlineInfo ? '● Online' : '○ Offline'}
</span>
</td>
<td style={{ padding: 8 }}>{onlineInfo ? onlineInfo.active_session_count : '-'}</td>
<td style={{ padding: 8, display: 'flex', gap: 8 }}>
<button onClick={() => statusMutation.mutate({ id: mitra.id, is_active: !mitra.is_active })}> <button onClick={() => statusMutation.mutate({ id: mitra.id, is_active: !mitra.is_active })}>
{mitra.is_active ? 'Nonaktifkan' : 'Aktifkan'} {mitra.is_active ? 'Nonaktifkan' : 'Aktifkan'}
</button> </button>
<button onClick={() => setLogsForMitra(logsForMitra === mitra.id ? null : mitra.id)}>
{logsForMitra === mitra.id ? 'Tutup Log' : 'Log Online'}
</button>
</td> </td>
</tr> </tr>
)
})}
</tbody>
</table>
{logsForMitra && (
<div style={{ marginTop: 16, padding: 16, border: '1px solid #eee' }}>
<h3>Log Online/Offline</h3>
{logsLoading ? (
<p>Loading...</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Waktu</th>
</tr>
</thead>
<tbody>
{logsData?.items?.map((log) => (
<tr key={log.id}>
<td style={{ padding: 8 }}>
<span style={{ color: log.status === 'online' ? 'green' : 'grey' }}>
{log.status === 'online' ? '● Online' : '○ Offline'}
</span>
</td>
<td style={{ padding: 8 }}>{new Date(log.timestamp).toLocaleString('id-ID')}</td>
</tr>
))} ))}
</tbody> </tbody>
</table> </table>
)}
</div>
)}
</div> </div>
) )
} }

View File

@@ -0,0 +1,131 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
const fetchSessions = async ({ status, page }) => {
const params = new URLSearchParams()
if (status) params.set('status', status)
params.set('page', page)
params.set('limit', '20')
const res = await apiClient.get(`/internal/sessions?${params}`)
return res.data.data
}
const fetchOnlineMitras = async () => {
const res = await apiClient.get('/internal/mitras/online')
return res.data.data
}
const rerouteSession = async ({ sessionId, new_mitra_id }) => {
const res = await apiClient.post(`/internal/sessions/${sessionId}/reroute`, { new_mitra_id })
return res.data.data
}
const STATUS_OPTIONS = [
{ value: '', label: 'Semua' },
{ value: 'active', label: 'Aktif' },
{ value: 'pending_acceptance', label: 'Menunggu Mitra' },
{ value: 'pending_payment', label: 'Menunggu Bayar' },
{ value: 'completed', label: 'Selesai' },
{ value: 'cancelled', label: 'Dibatalkan' },
{ value: 'expired', label: 'Kedaluwarsa' },
]
export default function SessionsPage() {
const queryClient = useQueryClient()
const [statusFilter, setStatusFilter] = useState('')
const [page, setPage] = useState(1)
const [rerouteTarget, setRerouteTarget] = useState({})
const { data, isLoading } = useQuery({
queryKey: ['sessions', statusFilter, page],
queryFn: () => fetchSessions({ status: statusFilter, page }),
refetchInterval: 10000,
})
const { data: onlineMitras } = useQuery({
queryKey: ['mitras-online-for-reroute'],
queryFn: fetchOnlineMitras,
})
const rerouteMutation = useMutation({
mutationFn: rerouteSession,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] })
setRerouteTarget({})
},
})
if (isLoading) return <div>Loading...</div>
return (
<div>
<h1>Sesi</h1>
<div style={{ marginBottom: 16, display: 'flex', gap: 8, alignItems: 'center' }}>
<label>Filter: </label>
<select value={statusFilter} onChange={e => { setStatusFilter(e.target.value); setPage(1) }}>
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Customer</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Mitra</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Waktu</th>
<th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
</tr>
</thead>
<tbody>
{data?.items?.map((session) => (
<tr key={session.id}>
<td style={{ padding: 8 }}>{session.customer_display_name}</td>
<td style={{ padding: 8 }}>{session.mitra_display_name ?? '-'}</td>
<td style={{ padding: 8 }}>{session.status}</td>
<td style={{ padding: 8 }}>{new Date(session.created_at).toLocaleString('id-ID')}</td>
<td style={{ padding: 8 }}>
{['active', 'pending_payment'].includes(session.status) && (
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<select
value={rerouteTarget[session.id] ?? ''}
onChange={e => setRerouteTarget(t => ({ ...t, [session.id]: e.target.value }))}
style={{ fontSize: 12 }}
>
<option value="">Reroute ke...</option>
{(onlineMitras ?? [])
.filter(m => m.id !== session.mitra_id)
.map(m => <option key={m.id} value={m.id}>{m.display_name}</option>)}
</select>
<button
disabled={!rerouteTarget[session.id] || rerouteMutation.isPending}
onClick={() => rerouteMutation.mutate({
sessionId: session.id,
new_mitra_id: rerouteTarget[session.id],
})}
style={{ fontSize: 12 }}
>
Reroute
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
{data && (
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center' }}>
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Prev</button>
<span>Halaman {data.page} dari {Math.ceil(data.total / data.limit) || 1}</span>
<button disabled={page >= Math.ceil(data.total / data.limit)} onClick={() => setPage(p => p + 1)}>Next</button>
</div>
)}
{rerouteMutation.isError && <p style={{ color: 'red', marginTop: 8 }}>Gagal reroute sesi.</p>}
</div>
)
}

View File

@@ -11,16 +11,36 @@ const updateAnonymityConfig = async (anonymity_enabled) => {
return res.data.data return res.data.data
} }
const fetchMaxCustomersConfig = async () => {
const res = await apiClient.get('/internal/config/max-customers-per-mitra')
return res.data.data
}
const updateMaxCustomersConfig = async (max_customers_per_mitra) => {
const res = await apiClient.patch('/internal/config/max-customers-per-mitra', { max_customers_per_mitra })
return res.data.data
}
export default function SettingsPage() { export default function SettingsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig }) const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
const mutation = useMutation({ const anonymityMutation = useMutation({
mutationFn: updateAnonymityConfig, mutationFn: updateAnonymityConfig,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-anonymity'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-anonymity'] }),
}) })
if (isLoading) return <div>Loading...</div> const { data: maxData, isLoading: maxLoading } = useQuery({
queryKey: ['config-max-customers'],
queryFn: fetchMaxCustomersConfig,
})
const maxMutation = useMutation({
mutationFn: updateMaxCustomersConfig,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-max-customers'] }),
})
if (isLoading || maxLoading) return <div>Loading...</div>
return ( return (
<div> <div>
@@ -33,12 +53,32 @@ export default function SettingsPage() {
<input <input
type="checkbox" type="checkbox"
checked={data?.anonymity_enabled ?? true} checked={data?.anonymity_enabled ?? true}
onChange={e => mutation.mutate(e.target.checked)} onChange={e => anonymityMutation.mutate(e.target.checked)}
disabled={mutation.isPending} disabled={anonymityMutation.isPending}
/> />
Izinkan pengguna anonim Izinkan pengguna anonim
</label> </label>
{mutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>} {anonymityMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Maks Customer per Mitra</h2>
<p>Jumlah maksimal customer yang bisa ditangani satu Mitra secara bersamaan. Perubahan hanya berlaku untuk chat baru.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="1"
value={maxData?.max_customers_per_mitra ?? 3}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 1) maxMutation.mutate(val)
}}
disabled={maxMutation.isPending}
style={{ width: 80 }}
/>
<span>customer</span>
</div>
{maxMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section> </section>
</div> </div>
) )

View File

@@ -2,7 +2,7 @@ import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
class ApiClient { class ApiClient {
static const String _baseUrl = String.fromEnvironment( static const String baseUrl = String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: 'https://api.halobestie.com', defaultValue: 'https://api.halobestie.com',
); );
@@ -10,7 +10,7 @@ class ApiClient {
late final Dio _dio; late final Dio _dio;
ApiClient() { ApiClient() {
_dio = Dio(BaseOptions(baseUrl: _baseUrl)); _dio = Dio(BaseOptions(baseUrl: baseUrl));
_dio.interceptors.add(InterceptorsWrapper( _dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async { onRequest: (options, handler) async {
final user = FirebaseAuth.instance.currentUser; final user = FirebaseAuth.instance.currentUser;
@@ -23,6 +23,11 @@ class ApiClient {
)); ));
} }
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? queryParameters}) async {
final response = await _dio.get(path, queryParameters: queryParameters);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> post(String path, {Map<String, dynamic>? data}) async { Future<Map<String, dynamic>> post(String path, {Map<String, dynamic>? data}) async {
final response = await _dio.post(path, data: data); final response = await _dio.post(path, data: data);
return response.data as Map<String, dynamic>; return response.data as Map<String, dynamic>;

View File

@@ -0,0 +1,165 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../api/api_client.dart';
// Events
abstract class ChatRequestEvent extends Equatable {
@override
List<Object?> get props => [];
}
class StartListening extends ChatRequestEvent {}
class StopListening extends ChatRequestEvent {}
class _RequestReceived extends ChatRequestEvent {
final Map<String, dynamic> data;
_RequestReceived(this.data);
@override
List<Object?> get props => [data];
}
class AcceptRequest extends ChatRequestEvent {
final String sessionId;
AcceptRequest(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
class DeclineRequest extends ChatRequestEvent {
final String sessionId;
DeclineRequest(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
// States
abstract class ChatRequestState extends Equatable {
@override
List<Object?> get props => [];
}
class ChatRequestIdle extends ChatRequestState {}
class ChatRequestListening extends ChatRequestState {}
class ChatRequestIncoming extends ChatRequestState {
final String sessionId;
ChatRequestIncoming(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
class ChatRequestAccepting extends ChatRequestState {}
class ChatRequestAccepted extends ChatRequestState {
final Map<String, dynamic> session;
ChatRequestAccepted(this.session);
@override
List<Object?> get props => [session];
}
class ChatRequestError extends ChatRequestState {
final String message;
ChatRequestError(this.message);
@override
List<Object?> get props => [message];
}
// Bloc
class ChatRequestBloc extends Bloc<ChatRequestEvent, ChatRequestState> {
final ApiClient apiClient;
StreamSubscription? _sseSubscription;
ChatRequestBloc({required this.apiClient}) : super(ChatRequestIdle()) {
on<StartListening>(_onStartListening);
on<StopListening>(_onStopListening);
on<_RequestReceived>(_onRequestReceived);
on<AcceptRequest>(_onAcceptRequest);
on<DeclineRequest>(_onDeclineRequest);
}
Future<void> _onStartListening(StartListening event, Emitter<ChatRequestState> emit) async {
_stopSSE();
emit(ChatRequestListening());
_listenToSSE();
}
Future<void> _onStopListening(StopListening event, Emitter<ChatRequestState> emit) async {
_stopSSE();
emit(ChatRequestIdle());
}
void _listenToSSE() {
final dio = Dio(BaseOptions(baseUrl: ApiClient.baseUrl));
dio.get(
'/api/mitra/chat-requests/incoming',
options: Options(responseType: ResponseType.stream),
).then((response) {
final stream = response.data.stream as Stream<List<int>>;
_sseSubscription = stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.where((line) => line.startsWith('data: '))
.map((line) => jsonDecode(line.substring(6)) as Map<String, dynamic>)
.listen(
(data) => add(_RequestReceived(data)),
onError: (_) {},
);
}).catchError((_) {});
}
Future<void> _onRequestReceived(_RequestReceived event, Emitter<ChatRequestState> emit) async {
final data = event.data;
final type = data['type'] as String?;
if (type == 'chat_request') {
emit(ChatRequestIncoming(data['session_id'] as String));
} else if (type == 'chat_request_closed') {
// Request was taken by another mitra or cancelled
if (state is ChatRequestIncoming) {
emit(ChatRequestListening());
}
} else if (type == 'session_rerouted') {
// A session was rerouted away from us — refresh active sessions
emit(ChatRequestListening());
} else if (type == 'session_assigned') {
// A session was force-assigned to us
emit(ChatRequestAccepted({'session_id': data['session_id']}));
}
}
Future<void> _onAcceptRequest(AcceptRequest event, Emitter<ChatRequestState> emit) async {
emit(ChatRequestAccepting());
try {
final response = await apiClient.post('/api/mitra/chat-requests/${event.sessionId}/accept');
emit(ChatRequestAccepted(response['data'] as Map<String, dynamic>));
} on DioException catch (e) {
final code = e.response?.data?['error']?['code'];
if (code == 'REQUEST_UNAVAILABLE') {
emit(ChatRequestListening());
} else {
emit(ChatRequestError('Gagal menerima. Coba lagi.'));
}
}
}
Future<void> _onDeclineRequest(DeclineRequest event, Emitter<ChatRequestState> emit) async {
try {
await apiClient.post('/api/mitra/chat-requests/${event.sessionId}/decline');
} catch (_) {}
emit(ChatRequestListening());
}
void _stopSSE() {
_sseSubscription?.cancel();
_sseSubscription = null;
}
@override
Future<void> close() {
_stopSSE();
return super.close();
}
}

View File

@@ -0,0 +1,129 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../api/api_client.dart';
// Events
abstract class StatusEvent extends Equatable {
@override
List<Object?> get props => [];
}
class StatusLoadRequested extends StatusEvent {}
class ToggleOnline extends StatusEvent {}
class ToggleOffline extends StatusEvent {}
class HeartbeatTick extends StatusEvent {}
class AppPaused extends StatusEvent {}
class AppResumed extends StatusEvent {}
// States
abstract class StatusState extends Equatable {
@override
List<Object?> get props => [];
}
class StatusInitial extends StatusState {}
class StatusLoaded extends StatusState {
final bool isOnline;
StatusLoaded({required this.isOnline});
@override
List<Object?> get props => [isOnline];
}
class StatusLoading extends StatusState {}
class StatusError extends StatusState {
final String message;
StatusError(this.message);
@override
List<Object?> get props => [message];
}
// Bloc
class StatusBloc extends Bloc<StatusEvent, StatusState> {
final ApiClient apiClient;
Timer? _heartbeatTimer;
StatusBloc({required this.apiClient}) : super(StatusInitial()) {
on<StatusLoadRequested>(_onLoad);
on<ToggleOnline>(_onToggleOnline);
on<ToggleOffline>(_onToggleOffline);
on<HeartbeatTick>(_onHeartbeat);
on<AppPaused>(_onAppPaused);
on<AppResumed>(_onAppResumed);
}
Future<void> _onLoad(StatusLoadRequested event, Emitter<StatusState> emit) async {
try {
final response = await apiClient.get('/api/mitra/status');
final data = response['data'] as Map<String, dynamic>;
emit(StatusLoaded(isOnline: data['is_online'] as bool));
} catch (e) {
emit(StatusLoaded(isOnline: false));
}
}
Future<void> _onToggleOnline(ToggleOnline event, Emitter<StatusState> emit) async {
emit(StatusLoading());
try {
await apiClient.post('/api/mitra/status/online');
_startHeartbeat();
emit(StatusLoaded(isOnline: true));
} catch (e) {
emit(StatusError('Gagal mengubah status. Coba lagi.'));
}
}
Future<void> _onToggleOffline(ToggleOffline event, Emitter<StatusState> emit) async {
emit(StatusLoading());
try {
await apiClient.post('/api/mitra/status/offline');
_stopHeartbeat();
emit(StatusLoaded(isOnline: false));
} catch (e) {
emit(StatusError('Gagal mengubah status. Coba lagi.'));
}
}
Future<void> _onHeartbeat(HeartbeatTick event, Emitter<StatusState> emit) async {
try {
await apiClient.post('/api/mitra/status/heartbeat');
} catch (_) {
// Heartbeat failure is non-critical; server will auto-offline after 45s
}
}
Future<void> _onAppPaused(AppPaused event, Emitter<StatusState> emit) async {
if (state is StatusLoaded && (state as StatusLoaded).isOnline) {
try {
await apiClient.post('/api/mitra/status/offline');
} catch (_) {}
_stopHeartbeat();
emit(StatusLoaded(isOnline: false));
}
}
Future<void> _onAppResumed(AppResumed event, Emitter<StatusState> emit) async {
// Do NOT auto-set online on resume; mitra must explicitly toggle
add(StatusLoadRequested());
}
void _startHeartbeat() {
_stopHeartbeat();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 15), (_) {
add(HeartbeatTick());
});
}
void _stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
@override
Future<void> close() {
_stopHeartbeat();
return super.close();
}
}

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/api/api_client.dart';
class ActiveSessionsScreen extends StatefulWidget {
const ActiveSessionsScreen({super.key});
@override
State<ActiveSessionsScreen> createState() => _ActiveSessionsScreenState();
}
class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> {
List<Map<String, dynamic>> _sessions = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadSessions();
}
Future<void> _loadSessions() async {
try {
final apiClient = context.read<ApiClient>();
final response = await apiClient.get('/api/mitra/chat-requests/sessions/active');
setState(() {
_sessions = List<Map<String, dynamic>>.from(response['data'] as List);
_loading = false;
});
} catch (_) {
setState(() => _loading = false);
}
}
Future<void> _endSession(String sessionId) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Akhiri Sesi?'),
content: const Text('Apakah kamu yakin ingin mengakhiri sesi ini?'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Batal')),
TextButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Ya, Akhiri')),
],
),
);
if (confirmed == true) {
try {
final apiClient = context.read<ApiClient>();
await apiClient.post('/api/mitra/chat-requests/sessions/$sessionId/end');
_loadSessions();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Gagal mengakhiri sesi.')),
);
}
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sesi Aktif')),
body: _loading
? const Center(child: CircularProgressIndicator())
: _sessions.isEmpty
? const Center(child: Text('Tidak ada sesi aktif.'))
: ListView.builder(
itemCount: _sessions.length,
itemBuilder: (context, index) {
final session = _sessions[index];
return ListTile(
leading: const Icon(Icons.person),
title: Text(session['customer_display_name'] as String? ?? 'Customer'),
subtitle: Text('Status: ${session['status']}'),
trailing: TextButton(
onPressed: () => _endSession(session['id'] as String),
child: const Text('Akhiri', style: TextStyle(color: Colors.red)),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/chat/chat_request_bloc.dart';
class IncomingRequestSheet extends StatelessWidget {
final String sessionId;
const IncomingRequestSheet({super.key, required this.sessionId});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.chat, size: 48, color: Colors.blue),
const SizedBox(height: 16),
const Text(
'Ada permintaan chat baru!',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Seorang customer ingin curhat denganmu.',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
context.read<ChatRequestBloc>().add(DeclineRequest(sessionId));
Navigator.of(context).pop();
},
child: const Text('Tolak'),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () {
context.read<ChatRequestBloc>().add(AcceptRequest(sessionId));
Navigator.of(context).pop();
},
child: const Text('Terima'),
),
),
],
),
],
),
);
}
}

View File

@@ -1,17 +1,49 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../core/auth/auth_bloc.dart'; import '../../core/auth/auth_bloc.dart';
import '../../core/status/status_bloc.dart';
import '../../core/chat/chat_request_bloc.dart';
import '../chat/widgets/incoming_request_sheet.dart';
/// Phase 1 placeholder — will be replaced in Phase 2 with session/chat features.
class HomeScreen extends StatelessWidget { class HomeScreen extends StatelessWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<AuthBloc, AuthState>( return MultiBlocListener(
builder: (context, state) { listeners: [
final displayName = state is AuthAuthenticated BlocListener<StatusBloc, StatusState>(
? state.profile['display_name'] as String listener: (context, state) {
if (state is StatusLoaded && state.isOnline) {
context.read<ChatRequestBloc>().add(StartListening());
} else if (state is StatusLoaded && !state.isOnline) {
context.read<ChatRequestBloc>().add(StopListening());
}
},
),
BlocListener<ChatRequestBloc, ChatRequestState>(
listener: (context, state) {
if (state is ChatRequestIncoming) {
showModalBottomSheet(
context: context,
isDismissible: false,
builder: (_) => BlocProvider.value(
value: context.read<ChatRequestBloc>(),
child: IncomingRequestSheet(sessionId: state.sessionId),
),
);
} else if (state is ChatRequestAccepted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sesi baru diterima!')),
);
}
},
),
],
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, authState) {
final displayName = authState is AuthAuthenticated
? authState.profile['display_name'] as String
: ''; : '';
return Scaffold( return Scaffold(
@@ -24,11 +56,95 @@ class HomeScreen extends StatelessWidget {
), ),
], ],
), ),
body: Center( body: Padding(
child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)), padding: const EdgeInsets.all(24),
child: Column(
children: [
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
const SizedBox(height: 32),
_StatusToggle(),
const SizedBox(height: 16),
_ActiveSessionsButton(),
],
),
),
);
},
),
);
}
}
class _StatusToggle extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<StatusBloc, StatusState>(
builder: (context, state) {
final isOnline = state is StatusLoaded && state.isOnline;
final isLoading = state is StatusLoading;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isOnline ? 'Online' : 'Offline',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: isOnline ? Colors.green : Colors.grey,
),
),
Text(
isOnline
? 'Kamu siap menerima chat'
: 'Aktifkan untuk menerima chat',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Switch(
value: isOnline,
activeColor: Colors.green,
onChanged: (_) {
final bloc = context.read<StatusBloc>();
if (isOnline) {
bloc.add(ToggleOffline());
} else {
bloc.add(ToggleOnline());
}
},
),
],
),
), ),
); );
}, },
); );
} }
} }
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'),
),
);
}
}

View File

@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/api/api_client.dart'; import 'core/api/api_client.dart';
import 'core/auth/auth_bloc.dart'; import 'core/auth/auth_bloc.dart';
import 'core/status/status_bloc.dart';
import 'core/chat/chat_request_bloc.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'router.dart'; import 'router.dart';
@@ -12,13 +14,59 @@ void main() async {
runApp(const App()); runApp(const App());
} }
class App extends StatelessWidget { class App extends StatefulWidget {
const App({super.key}); const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> with WidgetsBindingObserver {
late final ApiClient _apiClient;
late final StatusBloc _statusBloc;
late final ChatRequestBloc _chatRequestBloc;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_apiClient = ApiClient();
_statusBloc = StatusBloc(apiClient: _apiClient);
_chatRequestBloc = ChatRequestBloc(apiClient: _apiClient);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_statusBloc.close();
_chatRequestBloc.close();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) {
_statusBloc.add(AppPaused());
} else if (state == AppLifecycleState.resumed) {
_statusBloc.add(AppResumed());
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return MultiBlocProvider(
create: (_) => AuthBloc(apiClient: ApiClient())..add(AppStarted()), providers: [
BlocProvider(create: (_) => AuthBloc(apiClient: _apiClient)..add(AppStarted())),
BlocProvider.value(value: _statusBloc),
BlocProvider.value(value: _chatRequestBloc),
RepositoryProvider.value(value: _apiClient),
],
child: BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthAuthenticated) {
_statusBloc.add(StatusLoadRequested());
}
},
child: BlocBuilder<AuthBloc, AuthState>( child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) { builder: (context, state) {
return MaterialApp.router( return MaterialApp.router(
@@ -27,6 +75,7 @@ class App extends StatelessWidget {
); );
}, },
), ),
),
); );
} }
} }

View File

@@ -3,6 +3,7 @@ import 'core/auth/auth_bloc.dart';
import 'features/auth/screens/login_screen.dart'; import 'features/auth/screens/login_screen.dart';
import 'features/auth/screens/otp_screen.dart'; import 'features/auth/screens/otp_screen.dart';
import 'features/home/home_screen.dart'; import 'features/home/home_screen.dart';
import 'features/chat/screens/active_sessions_screen.dart';
GoRouter buildRouter(AuthBloc authBloc) { GoRouter buildRouter(AuthBloc authBloc) {
return GoRouter( return GoRouter(
@@ -20,6 +21,7 @@ GoRouter buildRouter(AuthBloc authBloc) {
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)), GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/sessions', builder: (_, __) => const ActiveSessionsScreen()),
], ],
); );
} }

322
requirement/phase2-plan.md Normal file
View File

@@ -0,0 +1,322 @@
# Phase 2 Implementation Plan: Mitra Online Status & Pairing Logic
## Summary of Clarified Requirements
| Topic | Decision |
|---|---|
| Mitra default status | Offline on app open; must explicitly toggle online |
| Auto-offline | Yes — on app close / connection loss |
| Online/offline logs | Backend + control center only; mitra don't see their own logs |
| Multi-device | No — single device per mitra |
| Customer active requests | One at a time; one active session at a time |
| Search timeout | 60 seconds, then "no bestie available" message |
| Cancel while searching | Yes |
| Pairing blast | Valkey pub/sub (in-app real-time); push notifications deferred |
| Race condition | First-come-first-served |
| Mitra decline/ignore | Allowed; request stays open until 60s timeout or customer cancel |
| Session end | Explicit end only (time-based deferred to next phase) |
| Payment placeholder | `pending_payment` status, skip to `active` for now |
| Max customer per mitra | Global config (not per-mitra) |
| Reroute | Forced assignment (acceptance-based deferred) |
| Reroute target | Online mitras only |
| Dashboard refresh | Polling-based auto-refresh now; SSE/push later |
---
## 1. Database Changes
### 1.1 New table: `mitra_online_status`
Tracks the current online/offline state per mitra.
| Column | Type | Notes |
|---|---|---|
| `id` | `SERIAL PRIMARY KEY` | |
| `mitra_id` | `INT REFERENCES mitras(id)` | Unique — one row per mitra |
| `is_online` | `BOOLEAN DEFAULT false` | |
| `last_online_at` | `TIMESTAMPTZ` | Last time they went online |
| `last_offline_at` | `TIMESTAMPTZ` | Last time they went offline |
| `updated_at` | `TIMESTAMPTZ` | |
### 1.2 New table: `mitra_online_logs`
Append-only log for control center reporting.
| Column | Type | Notes |
|---|---|---|
| `id` | `SERIAL PRIMARY KEY` | |
| `mitra_id` | `INT REFERENCES mitras(id)` | |
| `status` | `VARCHAR` | `'online'` or `'offline'` |
| `timestamp` | `TIMESTAMPTZ DEFAULT now()` | |
### 1.3 New table: `chat_sessions`
Tracks pairing and session lifecycle.
| Column | Type | Notes |
|---|---|---|
| `id` | `SERIAL PRIMARY KEY` | |
| `customer_id` | `INT REFERENCES customers(id)` | |
| `mitra_id` | `INT REFERENCES mitras(id)` | Nullable — null while searching |
| `status` | `VARCHAR` | See statuses below |
| `created_at` | `TIMESTAMPTZ DEFAULT now()` | |
| `paired_at` | `TIMESTAMPTZ` | When mitra accepted |
| `ended_at` | `TIMESTAMPTZ` | When session explicitly ended |
| `ended_by` | `VARCHAR` | `'customer'`, `'mitra'`, or `'system'` |
**Session statuses:**
- `searching` — customer requested, waiting for mitra
- `pending_acceptance` — blast sent, waiting for a mitra to accept
- `pending_payment` — mitra accepted, payment placeholder (auto-skip for now)
- `active` — session in progress
- `completed` — ended explicitly
- `cancelled` — customer cancelled during search
- `expired` — 60s timeout, no mitra accepted
- `rerouted` — session reassigned by control center (old record status)
### 1.4 New table: `chat_request_notifications`
Tracks which mitras were notified of a pairing request (for blast tracking).
| Column | Type | Notes |
|---|---|---|
| `id` | `SERIAL PRIMARY KEY` | |
| `session_id` | `INT REFERENCES chat_sessions(id)` | |
| `mitra_id` | `INT REFERENCES mitras(id)` | |
| `notified_at` | `TIMESTAMPTZ DEFAULT now()` | |
| `response` | `VARCHAR` | `'accepted'`, `'declined'`, `'ignored'` |
| `responded_at` | `TIMESTAMPTZ` | |
### 1.5 Extend `app_config`
Add new config key:
| Key | Value (JSONB) | Purpose |
|---|---|---|
| `max_customers_per_mitra` | `{ "value": 3 }` | Global max concurrent sessions per mitra |
---
## 2. Backend Changes
### 2.1 Valkey (Redis-compatible) Setup
- Add Valkey client as Fastify plugin (`src/plugins/valkey.js`)
- Used for pub/sub channels:
- `mitra:{mitra_id}:requests` — notify specific mitra of incoming chat requests
- `session:{session_id}:status` — notify customer of session status changes
- This pub/sub infrastructure will be reused for real-time chat in the next phase
### 2.2 New Public Routes — Mitra
| Method | Path | Purpose |
|---|---|---|
| `POST` | `/api/mitra/status/online` | Set mitra online |
| `POST` | `/api/mitra/status/offline` | Set mitra offline |
| `GET` | `/api/mitra/status` | Get own current status |
| `GET` | `/api/mitra/chat-requests/incoming` | SSE/long-poll endpoint for incoming chat request notifications |
| `POST` | `/api/mitra/chat-requests/:sessionId/accept` | Accept a chat request |
| `POST` | `/api/mitra/chat-requests/:sessionId/decline` | Decline a chat request |
| `GET` | `/api/mitra/sessions/active` | Get mitra's active sessions |
| `POST` | `/api/mitra/sessions/:sessionId/end` | End a session |
### 2.3 New Public Routes — Client
| Method | Path | Purpose |
|---|---|---|
| `POST` | `/api/client/chat/request` | Start a pairing request |
| `GET` | `/api/client/chat/request/:sessionId/status` | SSE/long-poll for pairing status updates |
| `POST` | `/api/client/chat/request/:sessionId/cancel` | Cancel a pairing request |
| `GET` | `/api/client/sessions/active` | Get customer's active session |
| `POST` | `/api/client/sessions/:sessionId/end` | End a session |
### 2.4 New Internal Routes — Control Center
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/internal/dashboard/stats` | Active chats, active mitras, active requests, customers per mitra |
| `GET` | `/internal/config/max-customers-per-mitra` | Get current max config |
| `PATCH` | `/internal/config/max-customers-per-mitra` | Update max config |
| `GET` | `/internal/sessions` | List sessions (filterable by status) |
| `GET` | `/internal/sessions/:sessionId` | Session detail |
| `POST` | `/internal/sessions/:sessionId/reroute` | Reroute session to another mitra (forced) |
| `GET` | `/internal/mitras/online` | List online mitras with active session count |
| `GET` | `/internal/mitras/:mitraId/online-logs` | Online/offline log for a mitra |
### 2.5 New Services
| Service | Responsibilities |
|---|---|
| `mitra-status.service.js` | Toggle online/offline, log entries, auto-offline logic |
| `pairing.service.js` | Create pairing request, blast to mitras, handle accept/decline, timeout (60s), cancel |
| `session.service.js` | Session lifecycle (active, end, reroute), active session queries |
| `dashboard.service.js` | Aggregate stats for control center dashboard |
### 2.6 Pairing Flow (Backend Detail)
```
1. Customer calls POST /api/client/chat/request
2. Backend creates chat_session (status: searching)
3. Backend queries available mitras:
- is_online = true
- active_session_count < max_customers_per_mitra config
4. If no mitra available → immediately return "no bestie available"
5. If mitras available:
- Update session status → pending_acceptance
- Create chat_request_notifications for each available mitra
- Publish to each mitra's Valkey channel
- Start 60s timeout timer (via setTimeout or scheduled job)
6. Mitra calls POST /api/mitra/chat-requests/:sessionId/accept
- Check session still in pending_acceptance (first-come-first-served)
- If yes → pair, set session status → pending_payment → active (skip payment)
- Publish status update to customer's session channel
- Mark other mitra notifications as "ignored"
7. On 60s timeout with no accept:
- Set session status → expired
- Publish timeout to customer's session channel
8. On customer cancel:
- Set session status → cancelled
- Notify mitras to dismiss the request
```
### 2.7 Auto-Offline Mechanism
- Mitra app sends periodic heartbeat (`POST /api/mitra/status/heartbeat`) every 15 seconds
- Backend tracks last heartbeat timestamp in `mitra_online_status`
- A background interval (every 30s) checks for mitras with `is_online = true` but no heartbeat in the last 45 seconds → auto-set offline
- This handles force-close, network loss, and crashes
---
## 3. Mitra App Changes
### 3.1 New BLoC: `StatusBloc`
Manages online/offline toggle and heartbeat.
**Events:** `ToggleOnline`, `ToggleOffline`, `HeartbeatTick`, `AutoOfflineDetected`
**States:** `StatusInitial`, `StatusOnline`, `StatusOffline`, `StatusLoading`, `StatusError`
- On `ToggleOnline` → call API, start heartbeat timer (15s interval)
- On `ToggleOffline` → call API, stop heartbeat timer
- On app lifecycle `paused`/`detached` → call offline API, stop heartbeat
### 3.2 New BLoC: `ChatRequestBloc`
Handles incoming chat request notifications.
**Events:** `StartListening`, `StopListening`, `RequestReceived`, `AcceptRequest`, `DeclineRequest`
**States:** `Idle`, `IncomingRequest(session)`, `Accepting`, `Accepted(session)`, `Declined`, `Error`
- Listens to SSE/long-poll endpoint for incoming requests while mitra is online
- Shows incoming request UI overlay/bottom sheet
### 3.3 Screen Changes
| Screen | Changes |
|---|---|
| Home screen | Add online/offline toggle switch at the top; show active session count |
| New: Incoming request sheet | Bottom sheet showing customer request with Accept/Decline buttons |
| New: Active sessions screen | List of current active sessions (name, duration) with End button |
### 3.4 App Lifecycle Handling
- Use `WidgetsBindingObserver` to detect app going to background/foreground
- On `paused`/`detached` → call offline API
- On `resumed` → do NOT auto-set online; mitra must explicitly toggle
---
## 4. Client App Changes
### 4.1 New BLoC: `PairingBloc`
Manages the pairing request lifecycle.
**Events:** `RequestPairing`, `CancelPairing`, `PairingStatusUpdate`, `PairingTimeout`
**States:** `PairingInitial`, `Searching`, `BestieFound(mitraName)`, `Paired(session)`, `NoBestieAvailable`, `Cancelled`, `Error`
- On `RequestPairing` → call API, start listening for status updates, start 60s local timer
- On status update `active` → emit `BestieFound` briefly, then `Paired`
- On timeout / expired → emit `NoBestieAvailable`
### 4.2 Screen Changes
| Screen | Changes |
|---|---|
| Home screen | Add "Mulai Curhat" CTA button (disabled if already has active session) |
| New: Searching screen | "Searching for Bestie..." with animation and Cancel button |
| New: Bestie found screen | Brief "Bestie ditemukan, menghubungkan kamu ke Bestie" message |
| New: Session active screen | Shows paired bestie name, End Session button |
| New: No bestie screen | "No bestie available, try again later" with retry button |
### 4.3 Navigation Updates
Add new routes in GoRouter:
- `/chat/searching` — searching screen
- `/chat/session/:sessionId` — active session screen
---
## 5. Control Center Changes
### 5.1 New Pages
| Page | Purpose |
|---|---|
| Dashboard page | Real-time stats: active chats, online mitras, pending requests, customers per mitra breakdown |
| Session management page | Table of all sessions (filterable by status), reroute action |
| Session detail page | Session info, customer/mitra details, reroute button |
### 5.2 Updated Pages
| Page | Changes |
|---|---|
| Settings page | Add "Max customers per mitra" config input |
| Mitra management page | Add online status indicator column, online/offline log link |
### 5.3 Dashboard Auto-Refresh
- Use React Query with `refetchInterval: 10000` (10s polling)
- Later can be swapped to SSE push without changing component logic
---
## 6. Implementation Order
Work should be done in this sequence to allow incremental testing:
| Step | What | Apps affected |
|---|---|---|
| 1 | Database migration (new tables + config) | Backend |
| 2 | Valkey plugin setup | Backend |
| 3 | Mitra online/offline status API + service + heartbeat | Backend |
| 4 | Mitra app: status toggle + heartbeat + lifecycle handling | Mitra app |
| 5 | Control center: max config + mitra online status column + logs | Control center |
| 6 | Pairing service + chat request APIs (mitra + client) | Backend |
| 7 | Client app: pairing flow (request, searching, found, paired screens) | Client app |
| 8 | Mitra app: incoming request notification + accept/decline | Mitra app |
| 9 | Session management APIs (end, reroute, list) | Backend |
| 10 | Client/mitra app: active session screen + end session | Both apps |
| 11 | Control center: dashboard + session management + reroute | Control center |
---
## 7. New Dependencies
| App | Package | Purpose |
|---|---|---|
| Backend | `ioredis` or `@valkey/valkey-glide` | Valkey/Redis client for pub/sub |
| Mitra app | `dio` (existing) | SSE via streaming response |
| Client app | `dio` (existing) | SSE via streaming response |
No major new dependencies needed — leveraging existing stack.
---
## 8. Note for Next Phase
**Valkey vs FCM — complementary, not competing:**
- **Valkey pub/sub** is the real-time transport for in-app events (pairing blasts, status updates, and later chat messages). Works when the app is in foreground.
- **FCM (Firebase Cloud Messaging)** should be added in the next phase for push notifications when the app is backgrounded/closed (e.g. "New message from customer"). Not needed this phase because mitras auto-go offline when the app is backgrounded — they only receive pairing requests while in foreground.
- Architecture: Valkey for real-time transport, FCM for push notifications. They layer on top of each other.

59
requirement/phase2.md Normal file
View File

@@ -0,0 +1,59 @@
# PRD: Mitra online status And Pairing Logic
# Overview
**Goal:** To build mitra online status and pairing logic between Mitra and Customer
**Success looks like:** Mitra can set status to online. System can pair Mitra and Customer for chat acceptance request. System can determine how many Customer connected with Mitra
## Background
- We need our Mitra do determine whether they are able to receive chat request or not through Online or Offline status
- System can keep track which Mitra is online and how many active session they currently handle
- System can have logic on how to pair Customer with Mitra. Either it is round robin or based round robin with weight
- Control center can determine how many Customer can Mitra handle globally
- When needed, control center user can re route request to other Mitra
# Functional Requirement
## Mitra online status
Mitra online status functional requirement:
1. Mitra can set their status online of offline from mobile app
2. Mitra with status online will be visible by system for pairing process
3. Online status will have their log. showing when they were online and when they were offline.
4. Be noted, that Mitra can be online and offline multiple times a day
## Customer Chat Request
Customer chat request on this phase will be just simple pairing, with lead to pairing process only. the real process will be on next phase.
Functional requirement:
1. Customer click CTA "Mulai Curhat"
2. System will look for mitra using Pairing method defined in different section
3. While system look for mitra and waiting for Mitra's confirmation, customer app will shows "Searching for Bestie" page
4. When mitra confirm chat request, customer app will briefly shows "Bestie ditemukan, menghubungkan kamu ke Bestie"
5. Customer will then showed a screen that they had paired with bestie named "bestie name"
## Mitra Pairing Backend
Pairing is a process in backend for system to look for Mitra's availability. Functional requirement:
1. Control Center user can config how many Customer, Mitra can handle
2. System will keep track of Mitra and Customer active chat session
3. When pairing request is coming, system will look for Mitra that is online and has active chat session less than maximum Mitra can handle
4. System then blast to all available Mitra that there is incoming chat Request
5. When one of the Mitra accept, system will pair them and start the session
6. Pairing flow will be:
1. Customer request for pairing
2. System blast to all available mitra
3. One of the Mitra will accept the Chat Request
4. Cutomer will receive notification that Bestie (Mitra {display name}) is accepting the request, it will trigger payment flow. Payment flow will be skipped on this phase
5. Once payment confirmed (which is skipped), System will show session start screen
## Control Center Chat Management
Control center will have following functionality:
1. Control Center user can set how many Customer can be handled by a Mitra
2. Upon Customer count per Mitra changes, system will only affect new incoming chat request, *AND NOT* the existing one
3. When needed, Control Center user can reroute (re assign) existing Chat Session to other Mitra. It can be rerouted both during mid session or when the session is waiting for payment. Payment is on next phase, so be prepare for it
4. Control Center user can see how many active chat, how many active Mitra, how many active request and how many Customer per Mitra
## Tech Stack
Use existing tech stack, and extend if needed, such as valkey for pubsub.