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:
10
CLAUDE.md
10
CLAUDE.md
@@ -30,7 +30,8 @@ Mental health chat platform connecting clients (users seeking support) with trai
|
|||||||
- **Control center is internal-only** — never expose its API routes to the public internet; protected via Nginx allow/deny + VPN
|
- **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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
47
backend/src/plugins/valkey.js
Normal file
47
backend/src/plugins/valkey.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
48
backend/src/routes/internal/session.routes.js
Normal file
48
backend/src/routes/internal/session.routes.js
Normal 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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
75
backend/src/routes/public/client.chat.routes.js
Normal file
75
backend/src/routes/public/client.chat.routes.js
Normal 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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
69
backend/src/routes/public/mitra.chat.routes.js
Normal file
69
backend/src/routes/public/mitra.chat.routes.js
Normal 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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
43
backend/src/routes/public/mitra.status.routes.js
Normal file
43
backend/src/routes/public/mitra.status.routes.js
Normal 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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
|||||||
28
backend/src/services/dashboard.service.js
Normal file
28
backend/src/services/dashboard.service.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
107
backend/src/services/mitra-status.service.js
Normal file
107
backend/src/services/mitra-status.service.js
Normal 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
|
||||||
|
}
|
||||||
247
backend/src/services/pairing.service.js
Normal file
247
backend/src/services/pairing.service.js
Normal 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
|
||||||
|
}
|
||||||
149
backend/src/services/session.service.js
Normal file
149
backend/src/services/session.service.js
Normal 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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
173
client_app/lib/core/pairing/pairing_bloc.dart
Normal file
173
client_app/lib/core/pairing/pairing_bloc.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
client_app/lib/features/chat/screens/no_bestie_screen.dart
Normal file
39
client_app/lib/features/chat/screens/no_bestie_screen.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
client_app/lib/features/chat/screens/searching_screen.dart
Normal file
55
client_app/lib/features/chat/screens/searching_screen.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
60
control_center/src/pages/dashboard/DashboardPage.jsx
Normal file
60
control_center/src/pages/dashboard/DashboardPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
131
control_center/src/pages/sessions/SessionsPage.jsx
Normal file
131
control_center/src/pages/sessions/SessionsPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
165
mitra_app/lib/core/chat/chat_request_bloc.dart
Normal file
165
mitra_app/lib/core/chat/chat_request_bloc.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
129
mitra_app/lib/core/status/status_bloc.dart
Normal file
129
mitra_app/lib/core/status/status_bloc.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
322
requirement/phase2-plan.md
Normal 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
59
requirement/phase2.md
Normal 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.
|
||||||
Reference in New Issue
Block a user