Phase 3 scaffold: chat engine (WebSocket, FCM, pricing, timer, extension, history)
- Backend: WebSocket plugin, chat/pricing/timer/extension/closure/notification services - Client app: ChatBloc, pricing dialog, chat screen with message status, extension/goodbye flow, history - Mitra app: MitraChatBloc, ExtensionBloc, chat screen, extension accept/reject, history - Control center: free trial, extension timeout, early end config toggles - DB migration: chat_messages, session_closures, session_extensions, customer_transactions tables Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,7 @@ Mental health chat platform connecting clients (users seeking support) with trai
|
|||||||
- **Control center is internal-only** — never expose its API routes to the public internet; protected via Nginx allow/deny + VPN
|
- **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** use Valkey pub/sub for in-app events; FCM push notifications planned for next phase
|
- **Real-time features** use WebSocket for chat + Valkey pub/sub as backend bus; FCM push for background notifications
|
||||||
- **Pairing** uses blast-to-all-available-mitras with first-come-first-served acceptance
|
- **Pairing** uses blast-to-all-available-mitras with first-come-first-served acceptance
|
||||||
|
|
||||||
## Current Progress
|
## Current Progress
|
||||||
@@ -47,6 +47,12 @@ Mental health chat platform connecting clients (users seeking support) with trai
|
|||||||
- mitra_app: online/offline toggle, heartbeat + lifecycle handling, incoming request notification, active sessions screen
|
- 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
|
- control_center: dashboard (auto-refresh), max customers per mitra config, session management + reroute, mitra online logs
|
||||||
- Docs: `requirement/phase2.md`, `requirement/phase2-plan.md`
|
- Docs: `requirement/phase2.md`, `requirement/phase2-plan.md`
|
||||||
|
- **Phase 3 (Chat Engine)** — fully scaffolded
|
||||||
|
- Backend: WebSocket plugin, chat message service, session timer (backend-authoritative), extension/closure services, FCM push notifications, pricing service (mock tiers + free trial)
|
||||||
|
- client_app: ChatBloc (WebSocket), ChatOpeningBloc (pricing), SessionClosureBloc, chat screen with message status (sent/delivered/read), typing indicator, extension/goodbye flow, chat history + transcript screens, FCM setup
|
||||||
|
- mitra_app: MitraChatBloc (WebSocket), ExtensionBloc, chat screen, extension accept/reject, goodbye flow, chat history + transcript screens, FCM setup
|
||||||
|
- control_center: free trial config, extension timeout config, early end toggles (mitra/customer)
|
||||||
|
- Docs: `requirement/phase3.md`, `requirement/phase3-plan.md`
|
||||||
|
|
||||||
## Domain Concepts
|
## Domain Concepts
|
||||||
|
|
||||||
|
|||||||
73
backend/package-lock.json
generated
73
backend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^9.0.1",
|
"@fastify/cors": "^9.0.1",
|
||||||
"@fastify/sensible": "^5.6.0",
|
"@fastify/sensible": "^5.6.0",
|
||||||
|
"@fastify/websocket": "^11.2.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"firebase-admin": "^12.2.0",
|
"firebase-admin": "^12.2.0",
|
||||||
@@ -88,6 +89,43 @@
|
|||||||
"vary": "^1.1.2"
|
"vary": "^1.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/websocket": {
|
||||||
|
"version": "11.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
|
||||||
|
"integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"duplexify": "^4.1.3",
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"ws": "^8.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/websocket/node_modules/fastify-plugin": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@firebase/app-check-interop-types": {
|
"node_modules/@firebase/app-check-interop-types": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.2.tgz",
|
||||||
@@ -854,7 +892,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
||||||
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"end-of-stream": "^1.4.1",
|
"end-of-stream": "^1.4.1",
|
||||||
"inherits": "^2.0.3",
|
"inherits": "^2.0.3",
|
||||||
@@ -883,7 +920,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"once": "^1.4.0"
|
"once": "^1.4.0"
|
||||||
}
|
}
|
||||||
@@ -1943,7 +1979,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
@@ -2243,7 +2278,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"inherits": "^2.0.3",
|
"inherits": "^2.0.3",
|
||||||
"string_decoder": "^1.1.1",
|
"string_decoder": "^1.1.1",
|
||||||
@@ -2467,15 +2501,13 @@
|
|||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
|
||||||
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
@@ -2649,8 +2681,7 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
@@ -2737,8 +2768,28 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"optional": true
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^9.0.1",
|
"@fastify/cors": "^9.0.1",
|
||||||
"@fastify/sensible": "^5.6.0",
|
"@fastify/sensible": "^5.6.0",
|
||||||
|
"@fastify/websocket": "^11.2.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"firebase-admin": "^12.2.0",
|
"firebase-admin": "^12.2.0",
|
||||||
|
|||||||
@@ -8,22 +8,29 @@ import { sharedConfigRoutes } from './routes/public/shared.config.routes.js'
|
|||||||
import { mitraStatusRoutes } from './routes/public/mitra.status.routes.js'
|
import { mitraStatusRoutes } from './routes/public/mitra.status.routes.js'
|
||||||
import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js'
|
import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js'
|
||||||
import { clientChatRoutes } from './routes/public/client.chat.routes.js'
|
import { clientChatRoutes } from './routes/public/client.chat.routes.js'
|
||||||
|
import { sharedChatRoutes } from './routes/public/shared.chat.routes.js'
|
||||||
import { errorHandler } from './plugins/error-handler.js'
|
import { errorHandler } from './plugins/error-handler.js'
|
||||||
|
import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js'
|
||||||
|
|
||||||
export const buildPublicApp = async () => {
|
export const buildPublicApp = async () => {
|
||||||
const app = Fastify({ logger: true })
|
const app = Fastify({ logger: true })
|
||||||
|
|
||||||
await app.register(cors, { origin: true })
|
await app.register(cors, { origin: true })
|
||||||
await app.register(sensible)
|
await app.register(sensible)
|
||||||
|
await registerWebSocketPlugin(app)
|
||||||
app.setErrorHandler(errorHandler)
|
app.setErrorHandler(errorHandler)
|
||||||
|
|
||||||
app.register(customerRoutes, { prefix: '/api/shared/customer' })
|
app.register(customerRoutes, { prefix: '/api/shared/customer' })
|
||||||
app.register(sharedConfigRoutes, { prefix: '/api/shared/config' })
|
app.register(sharedConfigRoutes, { prefix: '/api/shared/config' })
|
||||||
|
app.register(sharedChatRoutes, { prefix: '/api/shared' })
|
||||||
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(mitraStatusRoutes, { prefix: '/api/mitra/status' })
|
||||||
app.register(mitraChatRoutes, { prefix: '/api/mitra/chat-requests' })
|
app.register(mitraChatRoutes, { prefix: '/api/mitra/chat-requests' })
|
||||||
app.register(clientChatRoutes, { prefix: '/api/client/chat' })
|
app.register(clientChatRoutes, { prefix: '/api/client/chat' })
|
||||||
|
|
||||||
|
// WebSocket route (registered at app level, not prefixed)
|
||||||
|
registerWebSocketRoute(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,125 @@ const migrate = async () => {
|
|||||||
ON CONFLICT (key) DO NOTHING
|
ON CONFLICT (key) DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// --- Phase 3: Chat Engine ---
|
||||||
|
|
||||||
|
// Add session duration/pricing columns to chat_sessions
|
||||||
|
await sql`
|
||||||
|
ALTER TABLE chat_sessions
|
||||||
|
ADD COLUMN IF NOT EXISTS duration_minutes INT,
|
||||||
|
ADD COLUMN IF NOT EXISTS price INT DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS is_free_trial BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS extended_minutes INT NOT NULL DEFAULT 0
|
||||||
|
`
|
||||||
|
|
||||||
|
// Add FCM token columns
|
||||||
|
await sql`
|
||||||
|
ALTER TABLE customers
|
||||||
|
ADD COLUMN IF NOT EXISTS fcm_token VARCHAR(255)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
ALTER TABLE mitras
|
||||||
|
ADD COLUMN IF NOT EXISTS fcm_token VARCHAR(255)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL REFERENCES chat_sessions(id),
|
||||||
|
sender_type VARCHAR(10) NOT NULL,
|
||||||
|
sender_id UUID NOT NULL,
|
||||||
|
type VARCHAR(20) NOT NULL DEFAULT 'text',
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
metadata JSONB,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'sent',
|
||||||
|
delivered_at TIMESTAMPTZ,
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_created
|
||||||
|
ON chat_messages (session_id, created_at)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_status
|
||||||
|
ON chat_messages (session_id, status)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS session_closures (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL REFERENCES chat_sessions(id),
|
||||||
|
user_type VARCHAR(10) NOT NULL,
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS session_extensions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL REFERENCES chat_sessions(id),
|
||||||
|
requested_duration_minutes INT NOT NULL,
|
||||||
|
requested_price INT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
responded_at TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS customer_transactions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
customer_id UUID NOT NULL REFERENCES customers(id),
|
||||||
|
session_id UUID NOT NULL REFERENCES chat_sessions(id),
|
||||||
|
type VARCHAR(20) NOT NULL,
|
||||||
|
amount INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_customer_transactions_customer_id
|
||||||
|
ON customer_transactions (customer_id)
|
||||||
|
`
|
||||||
|
|
||||||
|
// Phase 3 config keys
|
||||||
|
await sql`
|
||||||
|
INSERT INTO app_config (key, value)
|
||||||
|
VALUES ('free_trial_enabled', '{"value": true}')
|
||||||
|
ON CONFLICT (key) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
INSERT INTO app_config (key, value)
|
||||||
|
VALUES ('free_trial_duration_minutes', '{"value": 5}')
|
||||||
|
ON CONFLICT (key) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
INSERT INTO app_config (key, value)
|
||||||
|
VALUES ('extension_timeout_seconds', '{"value": 60}')
|
||||||
|
ON CONFLICT (key) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
INSERT INTO app_config (key, value)
|
||||||
|
VALUES ('early_end_mitra_enabled', '{"value": false}')
|
||||||
|
ON CONFLICT (key) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
INSERT INTO app_config (key, value)
|
||||||
|
VALUES ('early_end_customer_enabled', '{"value": false}')
|
||||||
|
ON CONFLICT (key) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
console.log('Migration complete.')
|
console.log('Migration complete.')
|
||||||
await sql.end()
|
await sql.end()
|
||||||
}
|
}
|
||||||
|
|||||||
151
backend/src/plugins/websocket.js
Normal file
151
backend/src/plugins/websocket.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import websocket from '@fastify/websocket'
|
||||||
|
import { verifyFirebaseToken } from './firebase.js'
|
||||||
|
import { getCustomerByFirebaseUid } from '../services/customer.service.js'
|
||||||
|
import { getMitraByFirebaseUid } from '../services/mitra.service.js'
|
||||||
|
import { subscribe, publish } from './valkey.js'
|
||||||
|
|
||||||
|
// Track active WebSocket connections: sessionId → { customer, mitra }
|
||||||
|
const sessionConnections = new Map()
|
||||||
|
|
||||||
|
// Track user → socket mapping for FCM fallback detection
|
||||||
|
const userSockets = new Map() // `customer:${id}` or `mitra:${id}` → socket
|
||||||
|
|
||||||
|
export const registerWebSocketPlugin = async (app) => {
|
||||||
|
await app.register(websocket)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isUserOnlineWs = (userType, userId) => {
|
||||||
|
const key = `${userType}:${userId}`
|
||||||
|
const socket = userSockets.get(key)
|
||||||
|
return socket && socket.readyState === 1 // WebSocket.OPEN
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSessionConnections = (sessionId) => {
|
||||||
|
return sessionConnections.get(sessionId) || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendToSocket = (socket, data) => {
|
||||||
|
if (socket && socket.readyState === 1) {
|
||||||
|
socket.send(JSON.stringify(data))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendToSessionParticipant = (sessionId, userType, data) => {
|
||||||
|
const conns = sessionConnections.get(sessionId)
|
||||||
|
if (!conns) return false
|
||||||
|
return sendToSocket(conns[userType], data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendToUser = (userType, userId, data) => {
|
||||||
|
const key = `${userType}:${userId}`
|
||||||
|
const socket = userSockets.get(key)
|
||||||
|
return sendToSocket(socket, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const registerWebSocketRoute = (app) => {
|
||||||
|
app.get('/api/shared/ws', { websocket: true }, (socket, request) => {
|
||||||
|
let authenticatedUser = null // { type: 'customer'|'mitra', id, sessionId }
|
||||||
|
let valkeyUnsubscribes = []
|
||||||
|
|
||||||
|
const send = (data) => sendToSocket(socket, data)
|
||||||
|
|
||||||
|
socket.on('message', async (raw) => {
|
||||||
|
let msg
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(raw.toString())
|
||||||
|
} catch {
|
||||||
|
send({ type: 'error', message: 'Invalid JSON' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle auth message
|
||||||
|
if (msg.type === 'auth') {
|
||||||
|
try {
|
||||||
|
const decoded = await verifyFirebaseToken(msg.token)
|
||||||
|
const customer = await getCustomerByFirebaseUid(decoded.uid)
|
||||||
|
const mitra = customer ? null : await getMitraByFirebaseUid(decoded.uid)
|
||||||
|
|
||||||
|
if (!customer && !mitra) {
|
||||||
|
send({ type: 'error', message: 'Account not found' })
|
||||||
|
socket.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const userType = customer ? 'customer' : 'mitra'
|
||||||
|
const userId = customer ? customer.id : mitra.id
|
||||||
|
const sessionId = msg.session_id
|
||||||
|
|
||||||
|
authenticatedUser = { type: userType, id: userId, sessionId }
|
||||||
|
|
||||||
|
// Register in connection maps
|
||||||
|
const userKey = `${userType}:${userId}`
|
||||||
|
userSockets.set(userKey, socket)
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
if (!sessionConnections.has(sessionId)) {
|
||||||
|
sessionConnections.set(sessionId, {})
|
||||||
|
}
|
||||||
|
sessionConnections.get(sessionId)[userType] = socket
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to session channel for events from other services
|
||||||
|
if (sessionId) {
|
||||||
|
const unsub = subscribe(`session:${sessionId}:chat`, (data) => {
|
||||||
|
// Don't echo messages back to sender
|
||||||
|
if (data._sender_type === userType && data._sender_id === userId) return
|
||||||
|
const { _sender_type, _sender_id, ...payload } = data
|
||||||
|
send(payload)
|
||||||
|
})
|
||||||
|
valkeyUnsubscribes.push(unsub)
|
||||||
|
}
|
||||||
|
|
||||||
|
send({ type: 'auth_ok', user_type: userType, user_id: userId })
|
||||||
|
} catch (err) {
|
||||||
|
send({ type: 'error', message: 'Authentication failed' })
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other messages require authentication
|
||||||
|
if (!authenticatedUser) {
|
||||||
|
send({ type: 'error', message: 'Not authenticated. Send auth message first.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route message types to handlers via Valkey pub/sub
|
||||||
|
const { type, ...payload } = msg
|
||||||
|
await publish(`session:${authenticatedUser.sessionId}:incoming`, {
|
||||||
|
type,
|
||||||
|
...payload,
|
||||||
|
_sender_type: authenticatedUser.type,
|
||||||
|
_sender_id: authenticatedUser.id,
|
||||||
|
_session_id: authenticatedUser.sessionId,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
if (authenticatedUser) {
|
||||||
|
const userKey = `${authenticatedUser.type}:${authenticatedUser.id}`
|
||||||
|
userSockets.delete(userKey)
|
||||||
|
|
||||||
|
if (authenticatedUser.sessionId) {
|
||||||
|
const conns = sessionConnections.get(authenticatedUser.sessionId)
|
||||||
|
if (conns) {
|
||||||
|
delete conns[authenticatedUser.type]
|
||||||
|
if (!conns.customer && !conns.mitra) {
|
||||||
|
sessionConnections.delete(authenticatedUser.sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up Valkey subscriptions
|
||||||
|
for (const unsub of valkeyUnsubscribes) {
|
||||||
|
unsub()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
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, getMaxCustomersPerMitra, setMaxCustomersPerMitra } from '../../services/config.service.js'
|
import {
|
||||||
|
getAnonymityConfig, setAnonymityConfig,
|
||||||
|
getMaxCustomersPerMitra, setMaxCustomersPerMitra,
|
||||||
|
getFreeTrialConfig, setFreeTrialConfig,
|
||||||
|
getExtensionTimeoutConfig, setExtensionTimeoutConfig,
|
||||||
|
getEarlyEndConfig, setEarlyEndConfig,
|
||||||
|
} from '../../services/config.service.js'
|
||||||
|
|
||||||
const attachCcUser = async (request, reply) => {
|
const attachCcUser = async (request, reply) => {
|
||||||
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
|
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
|
||||||
@@ -44,4 +50,55 @@ export const internalConfigRoutes = async (app) => {
|
|||||||
const config = await setMaxCustomersPerMitra(max_customers_per_mitra)
|
const config = await setMaxCustomersPerMitra(max_customers_per_mitra)
|
||||||
return reply.send({ success: true, data: config })
|
return reply.send({ success: true, data: config })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Phase 3: Free Trial ---
|
||||||
|
app.get('/free-trial', {
|
||||||
|
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const config = await getFreeTrialConfig()
|
||||||
|
return reply.send({ success: true, data: config })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/free-trial', {
|
||||||
|
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { enabled, duration_minutes } = request.body ?? {}
|
||||||
|
const config = await setFreeTrialConfig({ enabled, duration_minutes })
|
||||||
|
return reply.send({ success: true, data: config })
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Phase 3: Extension Timeout ---
|
||||||
|
app.get('/extension-timeout', {
|
||||||
|
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const config = await getExtensionTimeoutConfig()
|
||||||
|
return reply.send({ success: true, data: config })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/extension-timeout', {
|
||||||
|
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { extension_timeout_seconds } = request.body ?? {}
|
||||||
|
if (typeof extension_timeout_seconds !== 'number' || extension_timeout_seconds < 10) {
|
||||||
|
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'Must be a number >= 10' } })
|
||||||
|
}
|
||||||
|
const config = await setExtensionTimeoutConfig(extension_timeout_seconds)
|
||||||
|
return reply.send({ success: true, data: config })
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Phase 3: Early End ---
|
||||||
|
app.get('/early-end', {
|
||||||
|
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const config = await getEarlyEndConfig()
|
||||||
|
return reply.send({ success: true, data: config })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/early-end', {
|
||||||
|
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { mitra_enabled, customer_enabled } = request.body ?? {}
|
||||||
|
const config = await setEarlyEndConfig({ mitra_enabled, customer_enabled })
|
||||||
|
return reply.send({ success: true, data: config })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { authenticate } from '../../plugins/auth.js'
|
import { authenticate } from '../../plugins/auth.js'
|
||||||
import { getCustomerByFirebaseUid } from '../../services/customer.service.js'
|
import { getCustomerByFirebaseUid } from '../../services/customer.service.js'
|
||||||
import { createPairingRequest, cancelPairingRequest, getSessionStatus } from '../../services/pairing.service.js'
|
import { createPairingRequest, cancelPairingRequest, getSessionStatus } from '../../services/pairing.service.js'
|
||||||
import { getActiveSessionByCustomer, endSession } from '../../services/session.service.js'
|
import { getActiveSessionByCustomer, endSession, getCustomerHistory } from '../../services/session.service.js'
|
||||||
import { subscribe } from '../../plugins/valkey.js'
|
import { subscribe } from '../../plugins/valkey.js'
|
||||||
|
import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js'
|
||||||
|
import { requestExtension } from '../../services/extension.service.js'
|
||||||
|
|
||||||
const resolveCustomer = async (request, reply) => {
|
const resolveCustomer = async (request, reply) => {
|
||||||
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
|
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
|
||||||
@@ -16,8 +18,48 @@ const resolveCustomer = async (request, reply) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const clientChatRoutes = async (app) => {
|
export const clientChatRoutes = async (app) => {
|
||||||
|
// Get pricing tiers + free trial eligibility
|
||||||
|
app.get('/pricing', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
|
const pricing = await getPricingForCustomer(request.customer.id)
|
||||||
|
return reply.send({ success: true, data: pricing })
|
||||||
|
})
|
||||||
|
|
||||||
app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
const session = await createPairingRequest(request.customer.id)
|
const { duration_minutes, price, is_free_trial } = request.body || {}
|
||||||
|
|
||||||
|
// Validate selection
|
||||||
|
if (is_free_trial) {
|
||||||
|
const eligible = await isCustomerEligibleForFreeTrial(request.customer.id)
|
||||||
|
if (!eligible) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
success: false,
|
||||||
|
error: { code: 'FREE_TRIAL_INELIGIBLE', message: 'Not eligible for free trial' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const freeTrial = await getFreeTrial()
|
||||||
|
const session = await createPairingRequest(request.customer.id, {
|
||||||
|
duration_minutes: freeTrial.duration_minutes,
|
||||||
|
price: 0,
|
||||||
|
is_free_trial: true,
|
||||||
|
})
|
||||||
|
return reply.code(201).send({ success: true, data: session })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!duration_minutes || price === undefined) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
success: false,
|
||||||
|
error: { code: 'BAD_REQUEST', message: 'duration_minutes and price are required' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidTier(duration_minutes, price)) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
success: false,
|
||||||
|
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await createPairingRequest(request.customer.id, { duration_minutes, price, is_free_trial: false })
|
||||||
return reply.code(201).send({ success: true, data: session })
|
return reply.code(201).send({ success: true, data: session })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -72,4 +114,27 @@ export const clientChatRoutes = async (app) => {
|
|||||||
const session = await endSession(request.params.sessionId, 'customer')
|
const session = await endSession(request.params.sessionId, 'customer')
|
||||||
return reply.send({ success: true, data: session })
|
return reply.send({ success: true, data: session })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Request session extension
|
||||||
|
app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
|
const { duration_minutes, price } = request.body || {}
|
||||||
|
if (!duration_minutes || price === undefined) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
success: false,
|
||||||
|
error: { code: 'BAD_REQUEST', message: 'duration_minutes and price are required' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const extension = await requestExtension(request.params.sessionId, request.customer.id, { duration_minutes, price })
|
||||||
|
return reply.send({ success: true, data: extension })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Chat history
|
||||||
|
app.get('/history', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
|
const { page, limit } = request.query
|
||||||
|
const history = await getCustomerHistory(request.customer.id, {
|
||||||
|
page: page ? parseInt(page) : 1,
|
||||||
|
limit: limit ? parseInt(limit) : 20,
|
||||||
|
})
|
||||||
|
return reply.send({ success: true, data: history })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { authenticate } from '../../plugins/auth.js'
|
import { authenticate } from '../../plugins/auth.js'
|
||||||
import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
|
import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
|
||||||
import { acceptPairingRequest, declinePairingRequest } from '../../services/pairing.service.js'
|
import { acceptPairingRequest, declinePairingRequest } from '../../services/pairing.service.js'
|
||||||
import { getActiveSessionsByMitra, endSession } from '../../services/session.service.js'
|
import { getActiveSessionsByMitra, endSession, getMitraHistory } from '../../services/session.service.js'
|
||||||
import { subscribe } from '../../plugins/valkey.js'
|
import { subscribe } from '../../plugins/valkey.js'
|
||||||
|
import { respondToExtension } from '../../services/extension.service.js'
|
||||||
|
|
||||||
const resolveMitra = async (request, reply) => {
|
const resolveMitra = async (request, reply) => {
|
||||||
const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid)
|
const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid)
|
||||||
@@ -66,4 +67,27 @@ export const mitraChatRoutes = async (app) => {
|
|||||||
const session = await endSession(request.params.sessionId, 'mitra')
|
const session = await endSession(request.params.sessionId, 'mitra')
|
||||||
return reply.send({ success: true, data: session })
|
return reply.send({ success: true, data: session })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Respond to extension request
|
||||||
|
app.post('/sessions/:sessionId/extend-response', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||||
|
const { extension_id, accepted } = request.body || {}
|
||||||
|
if (!extension_id || accepted === undefined) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
success: false,
|
||||||
|
error: { code: 'BAD_REQUEST', message: 'extension_id and accepted are required' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const extension = await respondToExtension(extension_id, request.params.sessionId, request.mitra.id, accepted)
|
||||||
|
return reply.send({ success: true, data: extension })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Chat history
|
||||||
|
app.get('/history', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||||
|
const { page, limit } = request.query
|
||||||
|
const history = await getMitraHistory(request.mitra.id, {
|
||||||
|
page: page ? parseInt(page) : 1,
|
||||||
|
limit: limit ? parseInt(limit) : 20,
|
||||||
|
})
|
||||||
|
return reply.send({ success: true, data: history })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
79
backend/src/routes/public/shared.chat.routes.js
Normal file
79
backend/src/routes/public/shared.chat.routes.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { authenticate } from '../../plugins/auth.js'
|
||||||
|
import { getCustomerByFirebaseUid } from '../../services/customer.service.js'
|
||||||
|
import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
|
||||||
|
import { getMessages } from '../../services/chat.service.js'
|
||||||
|
import { getSessionClosures } from '../../services/closure.service.js'
|
||||||
|
import { registerDeviceToken } from '../../services/notification.service.js'
|
||||||
|
|
||||||
|
const resolveUser = async (request, reply) => {
|
||||||
|
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
|
||||||
|
if (customer) {
|
||||||
|
request.userType = 'customer'
|
||||||
|
request.userId = customer.id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid)
|
||||||
|
if (mitra) {
|
||||||
|
request.userType = 'mitra'
|
||||||
|
request.userId = mitra.id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return reply.code(404).send({
|
||||||
|
success: false,
|
||||||
|
error: { code: 'ACCOUNT_NOT_FOUND', message: 'Account not found' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sharedChatRoutes = async (app) => {
|
||||||
|
// Get messages for a session (paginated)
|
||||||
|
app.get('/chat/:sessionId/messages', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
|
||||||
|
const { sessionId } = request.params
|
||||||
|
const { limit, before } = request.query
|
||||||
|
const messages = await getMessages(sessionId, {
|
||||||
|
limit: limit ? parseInt(limit) : 50,
|
||||||
|
before,
|
||||||
|
})
|
||||||
|
return reply.send({ success: true, data: messages })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get session info
|
||||||
|
app.get('/chat/:sessionId/info', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
|
||||||
|
const { sessionId } = request.params
|
||||||
|
const { getSessionById } = await import('../../services/session.service.js')
|
||||||
|
const session = await getSessionById(sessionId)
|
||||||
|
if (!session) {
|
||||||
|
return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Session not found' } })
|
||||||
|
}
|
||||||
|
return reply.send({ success: true, data: session })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get full transcript (read-only, for history)
|
||||||
|
app.get('/chat/:sessionId/transcript', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
|
||||||
|
const { sessionId } = request.params
|
||||||
|
const messages = await getMessages(sessionId, { limit: 10000 })
|
||||||
|
const closures = await getSessionClosures(sessionId)
|
||||||
|
return reply.send({ success: true, data: { messages, closures } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register FCM device token
|
||||||
|
app.post('/device-token', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
|
||||||
|
const { token } = request.body
|
||||||
|
if (!token) {
|
||||||
|
return reply.code(400).send({ success: false, error: { code: 'BAD_REQUEST', message: 'Token is required' } })
|
||||||
|
}
|
||||||
|
await registerDeviceToken(request.userType, request.userId, token)
|
||||||
|
return reply.send({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Submit goodbye/closure message
|
||||||
|
app.post('/sessions/:sessionId/close-message', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
|
||||||
|
const { sessionId } = request.params
|
||||||
|
const { message } = request.body
|
||||||
|
if (!message) {
|
||||||
|
return reply.code(400).send({ success: false, error: { code: 'BAD_REQUEST', message: 'Message is required' } })
|
||||||
|
}
|
||||||
|
const { submitClosureMessage } = await import('../../services/closure.service.js')
|
||||||
|
const closure = await submitClosureMessage(sessionId, request.userType, request.userId, message)
|
||||||
|
return reply.send({ success: true, data: closure })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ 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'
|
import { autoOfflineStaleMitras } from './services/mitra-status.service.js'
|
||||||
import { initFirebase } from './plugins/firebase.js'
|
import { initFirebase } from './plugins/firebase.js'
|
||||||
|
import { restoreActiveTimers } from './services/session-timer.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
|
||||||
@@ -19,6 +20,9 @@ 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}`)
|
||||||
|
|
||||||
|
// Restore session timers for active sessions (on server restart)
|
||||||
|
await restoreActiveTimers()
|
||||||
|
|
||||||
// Auto-offline mitras with stale heartbeat (every 30s)
|
// Auto-offline mitras with stale heartbeat (every 30s)
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
82
backend/src/services/chat-handler.service.js
Normal file
82
backend/src/services/chat-handler.service.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { subscribe } from '../plugins/valkey.js'
|
||||||
|
import { sendMessage, markDelivered, markRead } from './chat.service.js'
|
||||||
|
import { initiateEarlyEnd } from './closure.service.js'
|
||||||
|
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||||
|
|
||||||
|
// Track typing throttle per session+user
|
||||||
|
const typingLastSent = new Map()
|
||||||
|
const TYPING_THROTTLE_MS = 2000
|
||||||
|
|
||||||
|
// Active session listeners: sessionId → unsubscribe
|
||||||
|
const sessionListeners = new Map()
|
||||||
|
|
||||||
|
export const startSessionListener = (sessionId) => {
|
||||||
|
if (sessionListeners.has(sessionId)) return
|
||||||
|
|
||||||
|
const unsub = subscribe(`session:${sessionId}:incoming`, async (data) => {
|
||||||
|
const { type, _sender_type, _sender_id, _session_id, ...payload } = data
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case 'message':
|
||||||
|
await sendMessage({
|
||||||
|
sessionId: _session_id,
|
||||||
|
senderType: _sender_type,
|
||||||
|
senderId: _sender_id,
|
||||||
|
content: payload.content,
|
||||||
|
type: payload.message_type || 'text',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'typing':
|
||||||
|
handleTyping(_session_id, _sender_type)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'delivered':
|
||||||
|
await markDelivered(_session_id, _sender_type, payload.message_ids)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'read':
|
||||||
|
await markRead(_session_id, _sender_type, payload.message_ids)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'early_end':
|
||||||
|
await initiateEarlyEnd(_session_id, _sender_type)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[chat-handler] Error processing ${type}:`, err.message)
|
||||||
|
sendToSessionParticipant(_session_id, _sender_type, {
|
||||||
|
type: 'error',
|
||||||
|
message: err.message,
|
||||||
|
code: err.code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
sessionListeners.set(sessionId, unsub)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stopSessionListener = (sessionId) => {
|
||||||
|
const unsub = sessionListeners.get(sessionId)
|
||||||
|
if (unsub) {
|
||||||
|
unsub()
|
||||||
|
sessionListeners.delete(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTyping = (sessionId, senderType) => {
|
||||||
|
const key = `${sessionId}:${senderType}`
|
||||||
|
const now = Date.now()
|
||||||
|
const lastSent = typingLastSent.get(key) || 0
|
||||||
|
|
||||||
|
if (now - lastSent < TYPING_THROTTLE_MS) return
|
||||||
|
|
||||||
|
typingLastSent.set(key, now)
|
||||||
|
|
||||||
|
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
|
||||||
|
sendToSessionParticipant(sessionId, recipientType, {
|
||||||
|
type: 'typing',
|
||||||
|
sender_type: senderType,
|
||||||
|
})
|
||||||
|
}
|
||||||
127
backend/src/services/chat.service.js
Normal file
127
backend/src/services/chat.service.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { getDb } from '../db/client.js'
|
||||||
|
import { publish } from '../plugins/valkey.js'
|
||||||
|
import { isUserOnlineWs, sendToSessionParticipant } from '../plugins/websocket.js'
|
||||||
|
import { sendPushNotification } from './notification.service.js'
|
||||||
|
|
||||||
|
const sql = getDb()
|
||||||
|
|
||||||
|
export const sendMessage = async ({ sessionId, senderType, senderId, content, type = 'text' }) => {
|
||||||
|
// Verify session is active
|
||||||
|
const [session] = await sql`
|
||||||
|
SELECT id, customer_id, mitra_id, status FROM chat_sessions
|
||||||
|
WHERE id = ${sessionId} AND status = 'active'
|
||||||
|
`
|
||||||
|
if (!session) {
|
||||||
|
throw Object.assign(new Error('Session is not active'), {
|
||||||
|
code: 'SESSION_NOT_ACTIVE', statusCode: 409,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save message
|
||||||
|
const [message] = await sql`
|
||||||
|
INSERT INTO chat_messages (session_id, sender_type, sender_id, type, content, status)
|
||||||
|
VALUES (${sessionId}, ${senderType}, ${senderId}, ${type}, ${content}, 'sent')
|
||||||
|
RETURNING id, session_id, sender_type, sender_id, type, content, status, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
// Send ack to sender
|
||||||
|
sendToSessionParticipant(sessionId, senderType, {
|
||||||
|
type: 'message_ack',
|
||||||
|
message_id: message.id,
|
||||||
|
status: 'sent',
|
||||||
|
created_at: message.created_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Determine recipient
|
||||||
|
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
|
||||||
|
const recipientId = senderType === 'customer' ? session.mitra_id : session.customer_id
|
||||||
|
|
||||||
|
// Try to send via WebSocket
|
||||||
|
const delivered = sendToSessionParticipant(sessionId, recipientType, {
|
||||||
|
type: 'message',
|
||||||
|
message_id: message.id,
|
||||||
|
sender_type: senderType,
|
||||||
|
content: message.content,
|
||||||
|
message_type: message.type,
|
||||||
|
created_at: message.created_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
// If recipient not connected via WebSocket, send FCM push
|
||||||
|
if (!delivered && recipientId) {
|
||||||
|
await sendPushNotification(recipientType, recipientId, {
|
||||||
|
title: senderType === 'customer' ? 'Pesan baru dari Customer' : 'Pesan baru dari Bestie',
|
||||||
|
body: content.length > 100 ? content.substring(0, 100) + '...' : content,
|
||||||
|
data: { session_id: sessionId, type: 'chat_message' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
export const markDelivered = async (sessionId, senderType, messageIds) => {
|
||||||
|
if (!messageIds || messageIds.length === 0) return
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE chat_messages
|
||||||
|
SET status = 'delivered', delivered_at = NOW()
|
||||||
|
WHERE id = ANY(${messageIds})
|
||||||
|
AND session_id = ${sessionId}
|
||||||
|
AND status = 'sent'
|
||||||
|
`
|
||||||
|
|
||||||
|
// Notify sender about delivery
|
||||||
|
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
|
||||||
|
sendToSessionParticipant(sessionId, recipientType, {
|
||||||
|
type: 'message_status',
|
||||||
|
message_ids: messageIds,
|
||||||
|
status: 'delivered',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const markRead = async (sessionId, senderType, messageIds) => {
|
||||||
|
if (!messageIds || messageIds.length === 0) return
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE chat_messages
|
||||||
|
SET status = 'read', read_at = NOW()
|
||||||
|
WHERE id = ANY(${messageIds})
|
||||||
|
AND session_id = ${sessionId}
|
||||||
|
AND status IN ('sent', 'delivered')
|
||||||
|
`
|
||||||
|
|
||||||
|
// Notify sender about read
|
||||||
|
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
|
||||||
|
sendToSessionParticipant(sessionId, recipientType, {
|
||||||
|
type: 'message_status',
|
||||||
|
message_ids: messageIds,
|
||||||
|
status: 'read',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMessages = async (sessionId, { limit = 50, before } = {}) => {
|
||||||
|
const conditions = before
|
||||||
|
? sql`AND created_at < ${before}`
|
||||||
|
: sql``
|
||||||
|
|
||||||
|
const messages = await sql`
|
||||||
|
SELECT id, session_id, sender_type, sender_id, type, content, status, delivered_at, read_at, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE session_id = ${sessionId}
|
||||||
|
${conditions}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`
|
||||||
|
return messages.reverse() // Return in chronological order
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUndeliveredMessages = async (sessionId, recipientType) => {
|
||||||
|
const senderType = recipientType === 'customer' ? 'mitra' : 'customer'
|
||||||
|
return sql`
|
||||||
|
SELECT id, session_id, sender_type, sender_id, type, content, status, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE session_id = ${sessionId}
|
||||||
|
AND sender_type = ${senderType}
|
||||||
|
AND status = 'sent'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`
|
||||||
|
}
|
||||||
106
backend/src/services/closure.service.js
Normal file
106
backend/src/services/closure.service.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { getDb } from '../db/client.js'
|
||||||
|
import { publish } from '../plugins/valkey.js'
|
||||||
|
import { clearSessionTimer } from './session-timer.service.js'
|
||||||
|
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||||
|
|
||||||
|
const sql = getDb()
|
||||||
|
|
||||||
|
export const submitClosureMessage = async (sessionId, userType, userId, message) => {
|
||||||
|
// Verify session is in closing or active state (for early end)
|
||||||
|
const [session] = await sql`
|
||||||
|
SELECT id, status FROM chat_sessions
|
||||||
|
WHERE id = ${sessionId} AND status IN ('closing', 'active', 'extending')
|
||||||
|
`
|
||||||
|
if (!session) {
|
||||||
|
throw Object.assign(new Error('Session not found or already completed'), {
|
||||||
|
code: 'SESSION_NOT_ACTIVE', statusCode: 409,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save closure message
|
||||||
|
const [closure] = await sql`
|
||||||
|
INSERT INTO session_closures (session_id, user_type, user_id, message)
|
||||||
|
VALUES (${sessionId}, ${userType}, ${userId}, ${message})
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
RETURNING id, session_id, user_type, message, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
// Check if both parties have submitted
|
||||||
|
const closures = await sql`
|
||||||
|
SELECT user_type FROM session_closures WHERE session_id = ${sessionId}
|
||||||
|
`
|
||||||
|
const hasCustomer = closures.some((c) => c.user_type === 'customer')
|
||||||
|
const hasMitra = closures.some((c) => c.user_type === 'mitra')
|
||||||
|
|
||||||
|
if (hasCustomer && hasMitra) {
|
||||||
|
// Both submitted — complete the session
|
||||||
|
await completeSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return closure
|
||||||
|
}
|
||||||
|
|
||||||
|
export const completeSession = async (sessionId) => {
|
||||||
|
clearSessionTimer(sessionId)
|
||||||
|
|
||||||
|
const [session] = await sql`
|
||||||
|
UPDATE chat_sessions
|
||||||
|
SET status = 'completed', ended_at = NOW(), ended_by = 'system'
|
||||||
|
WHERE id = ${sessionId} AND status IN ('closing', 'active', 'extending')
|
||||||
|
RETURNING id, customer_id, mitra_id, status, ended_at
|
||||||
|
`
|
||||||
|
if (!session) return null
|
||||||
|
|
||||||
|
// Notify both parties
|
||||||
|
const data = { type: 'session_completed', session_id: sessionId }
|
||||||
|
sendToSessionParticipant(sessionId, 'customer', data)
|
||||||
|
sendToSessionParticipant(sessionId, 'mitra', data)
|
||||||
|
|
||||||
|
await publish(`session:${sessionId}:status`, { type: 'session_ended', session_id: sessionId })
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initiateEarlyEnd = async (sessionId, userType) => {
|
||||||
|
// Check if early end is enabled for this user type
|
||||||
|
const configKey = userType === 'mitra' ? 'early_end_mitra_enabled' : 'early_end_customer_enabled'
|
||||||
|
const [configRow] = await sql`SELECT value FROM app_config WHERE key = ${configKey}`
|
||||||
|
const enabled = configRow?.value?.value ?? false
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
throw Object.assign(new Error('Early end is not enabled'), {
|
||||||
|
code: 'EARLY_END_DISABLED', statusCode: 403,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move session to closing
|
||||||
|
const [session] = await sql`
|
||||||
|
UPDATE chat_sessions
|
||||||
|
SET status = 'closing', ended_by = ${userType}
|
||||||
|
WHERE id = ${sessionId} AND status = 'active'
|
||||||
|
RETURNING id, customer_id, mitra_id
|
||||||
|
`
|
||||||
|
if (!session) {
|
||||||
|
throw Object.assign(new Error('Session not active'), {
|
||||||
|
code: 'SESSION_NOT_ACTIVE', statusCode: 409,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSessionTimer(sessionId)
|
||||||
|
|
||||||
|
// Notify both parties to enter closure flow
|
||||||
|
const data = { type: 'session_closing', session_id: sessionId, ended_by: userType }
|
||||||
|
sendToSessionParticipant(sessionId, 'customer', data)
|
||||||
|
sendToSessionParticipant(sessionId, 'mitra', data)
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSessionClosures = async (sessionId) => {
|
||||||
|
return sql`
|
||||||
|
SELECT user_type, message, created_at
|
||||||
|
FROM session_closures
|
||||||
|
WHERE session_id = ${sessionId}
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`
|
||||||
|
}
|
||||||
@@ -29,3 +29,73 @@ export const setMaxCustomersPerMitra = async (value) => {
|
|||||||
`
|
`
|
||||||
return { max_customers_per_mitra: value }
|
return { max_customers_per_mitra: value }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Phase 3 config ---
|
||||||
|
|
||||||
|
export const getFreeTrialConfig = async () => {
|
||||||
|
const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'`
|
||||||
|
const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'`
|
||||||
|
return {
|
||||||
|
enabled: enabledRow?.value?.value ?? false,
|
||||||
|
duration_minutes: durationRow?.value?.value ?? 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => {
|
||||||
|
if (enabled !== undefined) {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO app_config (key, value, updated_at)
|
||||||
|
VALUES ('free_trial_enabled', ${sql.json({ value: enabled })}, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||||
|
`
|
||||||
|
}
|
||||||
|
if (duration_minutes !== undefined) {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO app_config (key, value, updated_at)
|
||||||
|
VALUES ('free_trial_duration_minutes', ${sql.json({ value: duration_minutes })}, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||||
|
`
|
||||||
|
}
|
||||||
|
return getFreeTrialConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getExtensionTimeoutConfig = async () => {
|
||||||
|
const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_timeout_seconds'`
|
||||||
|
return { extension_timeout_seconds: row?.value?.value ?? 60 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setExtensionTimeoutConfig = async (seconds) => {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO app_config (key, value, updated_at)
|
||||||
|
VALUES ('extension_timeout_seconds', ${sql.json({ value: seconds })}, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||||
|
`
|
||||||
|
return { extension_timeout_seconds: seconds }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getEarlyEndConfig = async () => {
|
||||||
|
const [mitraRow] = await sql`SELECT value FROM app_config WHERE key = 'early_end_mitra_enabled'`
|
||||||
|
const [customerRow] = await sql`SELECT value FROM app_config WHERE key = 'early_end_customer_enabled'`
|
||||||
|
return {
|
||||||
|
mitra_enabled: mitraRow?.value?.value ?? false,
|
||||||
|
customer_enabled: customerRow?.value?.value ?? false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setEarlyEndConfig = async ({ mitra_enabled, customer_enabled }) => {
|
||||||
|
if (mitra_enabled !== undefined) {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO app_config (key, value, updated_at)
|
||||||
|
VALUES ('early_end_mitra_enabled', ${sql.json({ value: mitra_enabled })}, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||||
|
`
|
||||||
|
}
|
||||||
|
if (customer_enabled !== undefined) {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO app_config (key, value, updated_at)
|
||||||
|
VALUES ('early_end_customer_enabled', ${sql.json({ value: customer_enabled })}, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||||
|
`
|
||||||
|
}
|
||||||
|
return getEarlyEndConfig()
|
||||||
|
}
|
||||||
|
|||||||
159
backend/src/services/extension.service.js
Normal file
159
backend/src/services/extension.service.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { getDb } from '../db/client.js'
|
||||||
|
import { publish } from '../plugins/valkey.js'
|
||||||
|
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||||
|
import { extendSessionTimer } from './session-timer.service.js'
|
||||||
|
|
||||||
|
const sql = getDb()
|
||||||
|
|
||||||
|
// Extension timeout map: extensionId → timeoutId
|
||||||
|
const extensionTimeouts = new Map()
|
||||||
|
|
||||||
|
const getExtensionTimeout = async () => {
|
||||||
|
const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_timeout_seconds'`
|
||||||
|
return (row?.value?.value ?? 60) * 1000 // Convert to ms
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestExtension = async (sessionId, customerId, { duration_minutes, price }) => {
|
||||||
|
// Verify session belongs to customer and just expired
|
||||||
|
const [session] = await sql`
|
||||||
|
SELECT id, customer_id, mitra_id, status FROM chat_sessions
|
||||||
|
WHERE id = ${sessionId} AND customer_id = ${customerId}
|
||||||
|
`
|
||||||
|
if (!session) {
|
||||||
|
throw Object.assign(new Error('Session not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create extension record
|
||||||
|
const [extension] = await sql`
|
||||||
|
INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status)
|
||||||
|
VALUES (${sessionId}, ${duration_minutes}, ${price}, 'pending')
|
||||||
|
RETURNING id, session_id, requested_duration_minutes, requested_price, status, requested_at
|
||||||
|
`
|
||||||
|
|
||||||
|
// Pause the session
|
||||||
|
await sql`UPDATE chat_sessions SET status = 'extending' WHERE id = ${sessionId}`
|
||||||
|
|
||||||
|
// Notify mitra
|
||||||
|
sendToSessionParticipant(sessionId, 'mitra', {
|
||||||
|
type: 'extension_request',
|
||||||
|
extension_id: extension.id,
|
||||||
|
session_id: sessionId,
|
||||||
|
duration_minutes,
|
||||||
|
price,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notify customer that chat is paused
|
||||||
|
sendToSessionParticipant(sessionId, 'customer', {
|
||||||
|
type: 'session_paused',
|
||||||
|
session_id: sessionId,
|
||||||
|
reason: 'extension_pending',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start timeout
|
||||||
|
const timeoutMs = await getExtensionTimeout()
|
||||||
|
const timeoutId = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await timeoutExtension(extension.id, sessionId)
|
||||||
|
} catch (_) {}
|
||||||
|
}, timeoutMs)
|
||||||
|
extensionTimeouts.set(extension.id, timeoutId)
|
||||||
|
|
||||||
|
return extension
|
||||||
|
}
|
||||||
|
|
||||||
|
export const respondToExtension = async (extensionId, sessionId, mitraId, accepted) => {
|
||||||
|
const status = accepted ? 'accepted' : 'rejected'
|
||||||
|
|
||||||
|
const [extension] = await sql`
|
||||||
|
UPDATE session_extensions
|
||||||
|
SET status = ${status}, responded_at = NOW()
|
||||||
|
WHERE id = ${extensionId} AND status = 'pending'
|
||||||
|
RETURNING id, session_id, requested_duration_minutes, requested_price, status
|
||||||
|
`
|
||||||
|
|
||||||
|
if (!extension) {
|
||||||
|
throw Object.assign(new Error('Extension not found or already resolved'), {
|
||||||
|
code: 'EXTENSION_RESOLVED', statusCode: 409,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear timeout
|
||||||
|
const timeoutId = extensionTimeouts.get(extensionId)
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
extensionTimeouts.delete(extensionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accepted) {
|
||||||
|
// Extend the session
|
||||||
|
await extendSessionTimer(extension.session_id, extension.requested_duration_minutes)
|
||||||
|
|
||||||
|
// Resume session
|
||||||
|
await sql`UPDATE chat_sessions SET status = 'active' WHERE id = ${extension.session_id}`
|
||||||
|
|
||||||
|
// Record transaction
|
||||||
|
await sql`
|
||||||
|
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
|
||||||
|
SELECT customer_id, id, 'extension', ${extension.requested_price}
|
||||||
|
FROM chat_sessions WHERE id = ${extension.session_id}
|
||||||
|
`
|
||||||
|
|
||||||
|
// Notify both parties
|
||||||
|
sendToSessionParticipant(sessionId, 'customer', {
|
||||||
|
type: 'extension_response',
|
||||||
|
accepted: true,
|
||||||
|
duration_minutes: extension.requested_duration_minutes,
|
||||||
|
})
|
||||||
|
sendToSessionParticipant(sessionId, 'mitra', {
|
||||||
|
type: 'session_resumed',
|
||||||
|
session_id: sessionId,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Rejected — proceed to closure
|
||||||
|
await sql`UPDATE chat_sessions SET status = 'closing' WHERE id = ${extension.session_id}`
|
||||||
|
|
||||||
|
sendToSessionParticipant(sessionId, 'customer', {
|
||||||
|
type: 'extension_response',
|
||||||
|
accepted: false,
|
||||||
|
})
|
||||||
|
sendToSessionParticipant(sessionId, 'mitra', {
|
||||||
|
type: 'session_closing',
|
||||||
|
session_id: sessionId,
|
||||||
|
})
|
||||||
|
sendToSessionParticipant(sessionId, 'customer', {
|
||||||
|
type: 'session_closing',
|
||||||
|
session_id: sessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return extension
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutExtension = async (extensionId, sessionId) => {
|
||||||
|
extensionTimeouts.delete(extensionId)
|
||||||
|
|
||||||
|
const [extension] = await sql`
|
||||||
|
UPDATE session_extensions
|
||||||
|
SET status = 'timeout', responded_at = NOW()
|
||||||
|
WHERE id = ${extensionId} AND status = 'pending'
|
||||||
|
RETURNING id, session_id
|
||||||
|
`
|
||||||
|
if (!extension) return
|
||||||
|
|
||||||
|
// Timeout = proceed to closure
|
||||||
|
await sql`UPDATE chat_sessions SET status = 'closing' WHERE id = ${extension.session_id}`
|
||||||
|
|
||||||
|
sendToSessionParticipant(sessionId, 'customer', {
|
||||||
|
type: 'extension_response',
|
||||||
|
accepted: false,
|
||||||
|
reason: 'timeout',
|
||||||
|
})
|
||||||
|
sendToSessionParticipant(sessionId, 'mitra', {
|
||||||
|
type: 'session_closing',
|
||||||
|
session_id: sessionId,
|
||||||
|
})
|
||||||
|
sendToSessionParticipant(sessionId, 'customer', {
|
||||||
|
type: 'session_closing',
|
||||||
|
session_id: sessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
52
backend/src/services/notification.service.js
Normal file
52
backend/src/services/notification.service.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import admin from 'firebase-admin'
|
||||||
|
import { getDb } from '../db/client.js'
|
||||||
|
|
||||||
|
const sql = getDb()
|
||||||
|
|
||||||
|
export const registerDeviceToken = async (userType, userId, fcmToken) => {
|
||||||
|
const table = userType === 'customer' ? 'customers' : 'mitras'
|
||||||
|
await sql`
|
||||||
|
UPDATE ${sql(table)}
|
||||||
|
SET fcm_token = ${fcmToken}
|
||||||
|
WHERE id = ${userId}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendPushNotification = async (recipientType, recipientId, { title, body, data = {} }) => {
|
||||||
|
const table = recipientType === 'customer' ? 'customers' : 'mitras'
|
||||||
|
const [user] = await sql`
|
||||||
|
SELECT fcm_token FROM ${sql(table)} WHERE id = ${recipientId}
|
||||||
|
`
|
||||||
|
|
||||||
|
if (!user?.fcm_token) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
await admin.messaging().send({
|
||||||
|
token: user.fcm_token,
|
||||||
|
notification: { title, body },
|
||||||
|
data: {
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(data).map(([k, v]) => [k, String(v)])
|
||||||
|
),
|
||||||
|
click_action: 'FLUTTER_NOTIFICATION_CLICK',
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
priority: 'high',
|
||||||
|
notification: { channelId: 'chat_messages' },
|
||||||
|
},
|
||||||
|
apns: {
|
||||||
|
payload: {
|
||||||
|
aps: { sound: 'default', badge: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[FCM] Failed to send to ${recipientType}:${recipientId}:`, err.message)
|
||||||
|
// Clear invalid token
|
||||||
|
if (err.code === 'messaging/registration-token-not-registered') {
|
||||||
|
await sql`UPDATE ${sql(table)} SET fcm_token = NULL WHERE id = ${recipientId}`
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { getDb } from '../db/client.js'
|
import { getDb } from '../db/client.js'
|
||||||
import { getMaxCustomersPerMitra } from './config.service.js'
|
import { getMaxCustomersPerMitra } from './config.service.js'
|
||||||
import { publish } from '../plugins/valkey.js'
|
import { publish } from '../plugins/valkey.js'
|
||||||
|
import { startSessionTimer } from './session-timer.service.js'
|
||||||
|
import { startSessionListener } from './chat-handler.service.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ export const findAvailableMitras = async () => {
|
|||||||
return mitras
|
return mitras
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createPairingRequest = async (customerId) => {
|
export const createPairingRequest = async (customerId, { duration_minutes, price, is_free_trial } = {}) => {
|
||||||
// Check for existing active session or request
|
// Check for existing active session or request
|
||||||
const [existing] = await sql`
|
const [existing] = await sql`
|
||||||
SELECT id, status FROM chat_sessions
|
SELECT id, status FROM chat_sessions
|
||||||
@@ -43,11 +45,11 @@ export const createPairingRequest = async (customerId) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session
|
// Create session with duration/price
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
INSERT INTO chat_sessions (customer_id, status)
|
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price, is_free_trial)
|
||||||
VALUES (${customerId}, 'pending_acceptance')
|
VALUES (${customerId}, 'pending_acceptance', ${duration_minutes || null}, ${price || 0}, ${is_free_trial || false})
|
||||||
RETURNING id, customer_id, status, created_at
|
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
// Create notifications for all available mitras
|
// Create notifications for all available mitras
|
||||||
@@ -111,13 +113,35 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
|||||||
pairingTimeouts.delete(sessionId)
|
pairingTimeouts.delete(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-skip payment for now: move to active
|
// Auto-skip payment for now: move to active and set expires_at
|
||||||
const [activeSession] = await sql`
|
const [activeSession] = await sql`
|
||||||
UPDATE chat_sessions SET status = 'active'
|
UPDATE chat_sessions
|
||||||
|
SET status = 'active',
|
||||||
|
expires_at = CASE
|
||||||
|
WHEN duration_minutes IS NOT NULL THEN NOW() + (duration_minutes || ' minutes')::interval
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
WHERE id = ${sessionId}
|
WHERE id = ${sessionId}
|
||||||
RETURNING id, customer_id, mitra_id, status, paired_at
|
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_free_trial, expires_at
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Record transaction
|
||||||
|
if (activeSession.duration_minutes) {
|
||||||
|
const txType = activeSession.is_free_trial ? 'free_trial' : 'paid'
|
||||||
|
await sql`
|
||||||
|
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
|
||||||
|
VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0})
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start session timer if duration is set
|
||||||
|
if (activeSession.expires_at) {
|
||||||
|
startSessionTimer(sessionId, activeSession.expires_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start chat message listener for this session
|
||||||
|
startSessionListener(sessionId)
|
||||||
|
|
||||||
// Get mitra display name for customer notification
|
// Get mitra display name for customer notification
|
||||||
const [mitra] = await sql`
|
const [mitra] = await sql`
|
||||||
SELECT display_name FROM mitras WHERE id = ${mitraId}
|
SELECT display_name FROM mitras WHERE id = ${mitraId}
|
||||||
|
|||||||
54
backend/src/services/pricing.service.js
Normal file
54
backend/src/services/pricing.service.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { getDb } from '../db/client.js'
|
||||||
|
|
||||||
|
const sql = getDb()
|
||||||
|
|
||||||
|
// Mock price tiers (will come from Control Center config later)
|
||||||
|
const PRICE_TIERS = [
|
||||||
|
{ duration_minutes: 15, price: 30000, label: '15 Menit' },
|
||||||
|
{ duration_minutes: 30, price: 60000, label: '30 Menit' },
|
||||||
|
{ duration_minutes: 45, price: 100000, label: '45 Menit' },
|
||||||
|
{ duration_minutes: 60, price: 150000, label: '60 Menit' },
|
||||||
|
{ duration_minutes: 1440, price: 250000, label: '24 Jam' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const getPriceTiers = () => PRICE_TIERS
|
||||||
|
|
||||||
|
export const isValidTier = (durationMinutes, price) => {
|
||||||
|
return PRICE_TIERS.some(
|
||||||
|
(t) => t.duration_minutes === durationMinutes && t.price === price
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getFreeTrial = async () => {
|
||||||
|
const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'`
|
||||||
|
const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'`
|
||||||
|
return {
|
||||||
|
enabled: enabledRow?.value?.value ?? false,
|
||||||
|
duration_minutes: durationRow?.value?.value ?? 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isCustomerEligibleForFreeTrial = async (customerId) => {
|
||||||
|
const freeTrial = await getFreeTrial()
|
||||||
|
if (!freeTrial.enabled) return false
|
||||||
|
|
||||||
|
const [tx] = await sql`
|
||||||
|
SELECT id FROM customer_transactions
|
||||||
|
WHERE customer_id = ${customerId}
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
return !tx // Eligible only if no transactions at all
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPricingForCustomer = async (customerId) => {
|
||||||
|
const tiers = getPriceTiers()
|
||||||
|
const freeTrialEligible = await isCustomerEligibleForFreeTrial(customerId)
|
||||||
|
const freeTrial = await getFreeTrial()
|
||||||
|
|
||||||
|
return {
|
||||||
|
tiers,
|
||||||
|
free_trial: freeTrialEligible
|
||||||
|
? { eligible: true, duration_minutes: freeTrial.duration_minutes }
|
||||||
|
: { eligible: false },
|
||||||
|
}
|
||||||
|
}
|
||||||
100
backend/src/services/session-timer.service.js
Normal file
100
backend/src/services/session-timer.service.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { getDb } from '../db/client.js'
|
||||||
|
import { publish } from '../plugins/valkey.js'
|
||||||
|
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||||
|
|
||||||
|
const sql = getDb()
|
||||||
|
|
||||||
|
// Active session timers: sessionId → { warningTimeout, expiryTimeout }
|
||||||
|
const sessionTimers = new Map()
|
||||||
|
|
||||||
|
export const startSessionTimer = (sessionId, expiresAt) => {
|
||||||
|
const now = Date.now()
|
||||||
|
const expiresMs = new Date(expiresAt).getTime()
|
||||||
|
const warningMs = expiresMs - 60_000 // 1 minute before expiry
|
||||||
|
|
||||||
|
// Clear any existing timers
|
||||||
|
clearSessionTimer(sessionId)
|
||||||
|
|
||||||
|
const timers = {}
|
||||||
|
|
||||||
|
// Warning timer (1 min before expiry)
|
||||||
|
if (warningMs > now) {
|
||||||
|
timers.warningTimeout = setTimeout(() => {
|
||||||
|
onSessionWarning(sessionId)
|
||||||
|
}, warningMs - now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiry timer
|
||||||
|
if (expiresMs > now) {
|
||||||
|
timers.expiryTimeout = setTimeout(() => {
|
||||||
|
onSessionExpired(sessionId)
|
||||||
|
}, expiresMs - now)
|
||||||
|
} else {
|
||||||
|
// Already expired
|
||||||
|
onSessionExpired(sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionTimers.set(sessionId, timers)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearSessionTimer = (sessionId) => {
|
||||||
|
const timers = sessionTimers.get(sessionId)
|
||||||
|
if (timers) {
|
||||||
|
if (timers.warningTimeout) clearTimeout(timers.warningTimeout)
|
||||||
|
if (timers.expiryTimeout) clearTimeout(timers.expiryTimeout)
|
||||||
|
sessionTimers.delete(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extendSessionTimer = async (sessionId, additionalMinutes) => {
|
||||||
|
const [session] = await sql`
|
||||||
|
UPDATE chat_sessions
|
||||||
|
SET expires_at = expires_at + ${additionalMinutes + ' minutes'}::interval,
|
||||||
|
extended_minutes = extended_minutes + ${additionalMinutes}
|
||||||
|
WHERE id = ${sessionId}
|
||||||
|
RETURNING id, expires_at
|
||||||
|
`
|
||||||
|
if (session) {
|
||||||
|
startSessionTimer(sessionId, session.expires_at)
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSessionWarning = (sessionId) => {
|
||||||
|
const data = { type: 'session_timer', remaining_seconds: 60, session_id: sessionId }
|
||||||
|
sendToSessionParticipant(sessionId, 'customer', data)
|
||||||
|
sendToSessionParticipant(sessionId, 'mitra', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSessionExpired = async (sessionId) => {
|
||||||
|
clearSessionTimer(sessionId)
|
||||||
|
|
||||||
|
// Check session is still active
|
||||||
|
const [session] = await sql`
|
||||||
|
SELECT id, status FROM chat_sessions WHERE id = ${sessionId} AND status = 'active'
|
||||||
|
`
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
// Notify both parties
|
||||||
|
const data = { type: 'session_expired', session_id: sessionId }
|
||||||
|
sendToSessionParticipant(sessionId, 'customer', data)
|
||||||
|
sendToSessionParticipant(sessionId, 'mitra', data)
|
||||||
|
|
||||||
|
// Also publish via Valkey for any listeners
|
||||||
|
await publish(`session:${sessionId}:status`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore timers for active sessions on server restart
|
||||||
|
export const restoreActiveTimers = async () => {
|
||||||
|
const activeSessions = await sql`
|
||||||
|
SELECT id, expires_at FROM chat_sessions
|
||||||
|
WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at > NOW()
|
||||||
|
`
|
||||||
|
for (const session of activeSessions) {
|
||||||
|
startSessionTimer(session.id, session.expires_at)
|
||||||
|
}
|
||||||
|
if (activeSessions.length > 0) {
|
||||||
|
console.log(`Restored ${activeSessions.length} session timer(s)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,12 @@ const sql = getDb()
|
|||||||
export const getActiveSessionByCustomer = async (customerId) => {
|
export const getActiveSessionByCustomer = async (customerId) => {
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at,
|
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at,
|
||||||
|
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
|
||||||
m.display_name AS mitra_display_name
|
m.display_name AS mitra_display_name
|
||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
||||||
WHERE cs.customer_id = ${customerId}
|
WHERE cs.customer_id = ${customerId}
|
||||||
AND cs.status IN ('active', 'pending_payment')
|
AND cs.status IN ('active', 'pending_payment', 'extending', 'closing')
|
||||||
ORDER BY cs.created_at DESC LIMIT 1
|
ORDER BY cs.created_at DESC LIMIT 1
|
||||||
`
|
`
|
||||||
return session
|
return session
|
||||||
@@ -19,11 +20,12 @@ export const getActiveSessionByCustomer = async (customerId) => {
|
|||||||
export const getActiveSessionsByMitra = async (mitraId) => {
|
export const getActiveSessionsByMitra = async (mitraId) => {
|
||||||
const sessions = await sql`
|
const sessions = await sql`
|
||||||
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at,
|
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at,
|
||||||
|
cs.duration_minutes, cs.expires_at, cs.extended_minutes,
|
||||||
c.display_name AS customer_display_name
|
c.display_name AS customer_display_name
|
||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
INNER JOIN customers c ON c.id = cs.customer_id
|
INNER JOIN customers c ON c.id = cs.customer_id
|
||||||
WHERE cs.mitra_id = ${mitraId}
|
WHERE cs.mitra_id = ${mitraId}
|
||||||
AND cs.status IN ('active', 'pending_payment')
|
AND cs.status IN ('active', 'pending_payment', 'extending', 'closing')
|
||||||
ORDER BY cs.created_at DESC
|
ORDER BY cs.created_at DESC
|
||||||
`
|
`
|
||||||
return sessions
|
return sessions
|
||||||
@@ -138,6 +140,7 @@ export const listSessions = async ({ page = 1, limit = 20, status } = {}) => {
|
|||||||
export const getSessionById = async (sessionId) => {
|
export const getSessionById = async (sessionId) => {
|
||||||
const [session] = await sql`
|
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,
|
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
|
||||||
|
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
|
||||||
c.display_name AS customer_display_name,
|
c.display_name AS customer_display_name,
|
||||||
m.display_name AS mitra_display_name
|
m.display_name AS mitra_display_name
|
||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
@@ -147,3 +150,45 @@ export const getSessionById = async (sessionId) => {
|
|||||||
`
|
`
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => {
|
||||||
|
const offset = (page - 1) * limit
|
||||||
|
const items = await sql`
|
||||||
|
SELECT cs.id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at,
|
||||||
|
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
|
||||||
|
m.display_name AS mitra_display_name,
|
||||||
|
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'mitra' LIMIT 1) AS mitra_closure_message,
|
||||||
|
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'customer' LIMIT 1) AS customer_closure_message
|
||||||
|
FROM chat_sessions cs
|
||||||
|
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
||||||
|
WHERE cs.customer_id = ${customerId}
|
||||||
|
AND cs.status = 'completed'
|
||||||
|
ORDER BY cs.ended_at DESC
|
||||||
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
|
`
|
||||||
|
const [{ count }] = await sql`
|
||||||
|
SELECT COUNT(*) FROM chat_sessions WHERE customer_id = ${customerId} AND status = 'completed'
|
||||||
|
`
|
||||||
|
return { items, total: Number(count), page, limit }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMitraHistory = async (mitraId, { page = 1, limit = 20 } = {}) => {
|
||||||
|
const offset = (page - 1) * limit
|
||||||
|
const items = await sql`
|
||||||
|
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at,
|
||||||
|
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
|
||||||
|
c.display_name AS customer_display_name,
|
||||||
|
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'mitra' LIMIT 1) AS mitra_closure_message,
|
||||||
|
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'customer' LIMIT 1) AS customer_closure_message
|
||||||
|
FROM chat_sessions cs
|
||||||
|
INNER JOIN customers c ON c.id = cs.customer_id
|
||||||
|
WHERE cs.mitra_id = ${mitraId}
|
||||||
|
AND cs.status = 'completed'
|
||||||
|
ORDER BY cs.ended_at DESC
|
||||||
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
|
`
|
||||||
|
const [{ count }] = await sql`
|
||||||
|
SELECT COUNT(*) FROM chat_sessions WHERE mitra_id = ${mitraId} AND status = 'completed'
|
||||||
|
`
|
||||||
|
return { items, total: Number(count), page, limit }
|
||||||
|
}
|
||||||
|
|||||||
385
client_app/lib/core/chat/chat_bloc.dart
Normal file
385
client_app/lib/core/chat/chat_bloc.dart
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
import '../api/api_client.dart';
|
||||||
|
|
||||||
|
// Events
|
||||||
|
abstract class ChatEvent extends Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConnectChat extends ChatEvent {
|
||||||
|
final String sessionId;
|
||||||
|
ConnectChat(this.sessionId);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [sessionId];
|
||||||
|
}
|
||||||
|
|
||||||
|
class DisconnectChat extends ChatEvent {}
|
||||||
|
|
||||||
|
class SendMessage extends ChatEvent {
|
||||||
|
final String content;
|
||||||
|
SendMessage(this.content);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [content];
|
||||||
|
}
|
||||||
|
|
||||||
|
class SendTyping extends ChatEvent {}
|
||||||
|
|
||||||
|
class _MessageReceived extends ChatEvent {
|
||||||
|
final Map<String, dynamic> data;
|
||||||
|
_MessageReceived(this.data);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [data];
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConnectionError extends ChatEvent {}
|
||||||
|
|
||||||
|
class MarkMessagesDelivered extends ChatEvent {
|
||||||
|
final List<String> messageIds;
|
||||||
|
MarkMessagesDelivered(this.messageIds);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [messageIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
class MarkMessagesRead extends ChatEvent {
|
||||||
|
final List<String> messageIds;
|
||||||
|
MarkMessagesRead(this.messageIds);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [messageIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
// States
|
||||||
|
abstract class ChatState extends Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatInitial extends ChatState {}
|
||||||
|
class ChatConnecting extends ChatState {}
|
||||||
|
|
||||||
|
class ChatConnected extends ChatState {
|
||||||
|
final List<ChatMessage> messages;
|
||||||
|
final bool isOtherTyping;
|
||||||
|
final int? remainingSeconds;
|
||||||
|
final bool sessionExpired;
|
||||||
|
final bool sessionPaused;
|
||||||
|
final bool sessionClosing;
|
||||||
|
final Map<String, dynamic>? extensionResponse;
|
||||||
|
|
||||||
|
ChatConnected({
|
||||||
|
required this.messages,
|
||||||
|
this.isOtherTyping = false,
|
||||||
|
this.remainingSeconds,
|
||||||
|
this.sessionExpired = false,
|
||||||
|
this.sessionPaused = false,
|
||||||
|
this.sessionClosing = false,
|
||||||
|
this.extensionResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
ChatConnected copyWith({
|
||||||
|
List<ChatMessage>? messages,
|
||||||
|
bool? isOtherTyping,
|
||||||
|
int? remainingSeconds,
|
||||||
|
bool? sessionExpired,
|
||||||
|
bool? sessionPaused,
|
||||||
|
bool? sessionClosing,
|
||||||
|
Map<String, dynamic>? extensionResponse,
|
||||||
|
}) {
|
||||||
|
return ChatConnected(
|
||||||
|
messages: messages ?? this.messages,
|
||||||
|
isOtherTyping: isOtherTyping ?? this.isOtherTyping,
|
||||||
|
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||||
|
sessionExpired: sessionExpired ?? this.sessionExpired,
|
||||||
|
sessionPaused: sessionPaused ?? this.sessionPaused,
|
||||||
|
sessionClosing: sessionClosing ?? this.sessionClosing,
|
||||||
|
extensionResponse: extensionResponse ?? this.extensionResponse,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [messages, isOtherTyping, remainingSeconds, sessionExpired, sessionPaused, sessionClosing, extensionResponse];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatError extends ChatState {
|
||||||
|
final String message;
|
||||||
|
ChatError(this.message);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [message];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message model
|
||||||
|
class ChatMessage {
|
||||||
|
final String id;
|
||||||
|
final String senderType;
|
||||||
|
final String content;
|
||||||
|
final String type;
|
||||||
|
final String status; // sending, sent, delivered, read
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
ChatMessage({
|
||||||
|
required this.id,
|
||||||
|
required this.senderType,
|
||||||
|
required this.content,
|
||||||
|
this.type = 'text',
|
||||||
|
this.status = 'sent',
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
ChatMessage copyWith({String? status}) {
|
||||||
|
return ChatMessage(
|
||||||
|
id: id,
|
||||||
|
senderType: senderType,
|
||||||
|
content: content,
|
||||||
|
type: type,
|
||||||
|
status: status ?? this.status,
|
||||||
|
createdAt: createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloc
|
||||||
|
class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||||
|
final ApiClient apiClient;
|
||||||
|
WebSocketChannel? _channel;
|
||||||
|
StreamSubscription? _wsSubscription;
|
||||||
|
Timer? _typingTimer;
|
||||||
|
|
||||||
|
ChatBloc({required this.apiClient}) : super(ChatInitial()) {
|
||||||
|
on<ConnectChat>(_onConnect);
|
||||||
|
on<DisconnectChat>(_onDisconnect);
|
||||||
|
on<SendMessage>(_onSendMessage);
|
||||||
|
on<SendTyping>(_onSendTyping);
|
||||||
|
on<_MessageReceived>(_onMessageReceived);
|
||||||
|
on<_ConnectionError>(_onConnectionError);
|
||||||
|
on<MarkMessagesDelivered>(_onMarkDelivered);
|
||||||
|
on<MarkMessagesRead>(_onMarkRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onConnect(ConnectChat event, Emitter<ChatState> emit) async {
|
||||||
|
emit(ChatConnecting());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load existing messages from API
|
||||||
|
final response = await apiClient.get(
|
||||||
|
'/api/shared/chat/${event.sessionId}/messages',
|
||||||
|
);
|
||||||
|
final messagesData = response['data'] as List<dynamic>;
|
||||||
|
final messages = messagesData.map((m) => ChatMessage(
|
||||||
|
id: m['id'] as String,
|
||||||
|
senderType: m['sender_type'] as String,
|
||||||
|
content: m['content'] as String,
|
||||||
|
type: m['type'] as String? ?? 'text',
|
||||||
|
status: m['status'] as String? ?? 'sent',
|
||||||
|
createdAt: DateTime.parse(m['created_at'] as String),
|
||||||
|
)).toList();
|
||||||
|
|
||||||
|
// Connect WebSocket
|
||||||
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
|
final token = await user?.getIdToken();
|
||||||
|
final wsUrl = ApiClient.baseUrl
|
||||||
|
.replaceFirst('https://', 'wss://')
|
||||||
|
.replaceFirst('http://', 'ws://');
|
||||||
|
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
|
||||||
|
|
||||||
|
_wsSubscription = _channel!.stream.listen(
|
||||||
|
(raw) {
|
||||||
|
final data = jsonDecode(raw as String) as Map<String, dynamic>;
|
||||||
|
add(_MessageReceived(data));
|
||||||
|
},
|
||||||
|
onError: (_) => add(_ConnectionError()),
|
||||||
|
onDone: () => add(_ConnectionError()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send auth message
|
||||||
|
_channel!.sink.add(jsonEncode({
|
||||||
|
'type': 'auth',
|
||||||
|
'token': token,
|
||||||
|
'session_id': event.sessionId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
emit(ChatConnected(messages: messages));
|
||||||
|
} catch (e) {
|
||||||
|
emit(ChatError('Gagal terhubung ke chat.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDisconnect(DisconnectChat event, Emitter<ChatState> emit) {
|
||||||
|
_cleanup();
|
||||||
|
emit(ChatInitial());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSendMessage(SendMessage event, Emitter<ChatState> emit) {
|
||||||
|
if (state is! ChatConnected || _channel == null) return;
|
||||||
|
final current = state as ChatConnected;
|
||||||
|
|
||||||
|
// Add message locally with 'sending' status
|
||||||
|
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final msg = ChatMessage(
|
||||||
|
id: tempId,
|
||||||
|
senderType: 'customer',
|
||||||
|
content: event.content,
|
||||||
|
status: 'sending',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||||
|
|
||||||
|
_channel!.sink.add(jsonEncode({
|
||||||
|
'type': 'message',
|
||||||
|
'content': event.content,
|
||||||
|
'_temp_id': tempId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSendTyping(SendTyping event, Emitter<ChatState> emit) {
|
||||||
|
if (_channel == null) return;
|
||||||
|
_channel!.sink.add(jsonEncode({'type': 'typing'}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<ChatState> emit) {
|
||||||
|
if (_channel == null) return;
|
||||||
|
_channel!.sink.add(jsonEncode({
|
||||||
|
'type': 'delivered',
|
||||||
|
'message_ids': event.messageIds,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMarkRead(MarkMessagesRead event, Emitter<ChatState> emit) {
|
||||||
|
if (_channel == null) return;
|
||||||
|
_channel!.sink.add(jsonEncode({
|
||||||
|
'type': 'read',
|
||||||
|
'message_ids': event.messageIds,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMessageReceived(_MessageReceived event, Emitter<ChatState> emit) {
|
||||||
|
if (state is! ChatConnected) return;
|
||||||
|
final current = state as ChatConnected;
|
||||||
|
final data = event.data;
|
||||||
|
final type = data['type'] as String?;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'auth_ok':
|
||||||
|
// Already connected
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'message':
|
||||||
|
final msg = ChatMessage(
|
||||||
|
id: data['message_id'] as String,
|
||||||
|
senderType: data['sender_type'] as String,
|
||||||
|
content: data['content'] as String,
|
||||||
|
type: data['message_type'] as String? ?? 'text',
|
||||||
|
status: 'sent',
|
||||||
|
createdAt: DateTime.parse(data['created_at'] as String),
|
||||||
|
);
|
||||||
|
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||||
|
// Auto-acknowledge delivery
|
||||||
|
add(MarkMessagesDelivered([msg.id]));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'message_ack':
|
||||||
|
final messageId = data['message_id'] as String;
|
||||||
|
final status = data['status'] as String;
|
||||||
|
final updatedMessages = current.messages.map((m) {
|
||||||
|
if (m.status == 'sending') {
|
||||||
|
return m.copyWith(status: status);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}).toList();
|
||||||
|
// Replace temp ID with real ID
|
||||||
|
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == 'customer');
|
||||||
|
if (idx >= 0) {
|
||||||
|
final old = updatedMessages[idx];
|
||||||
|
updatedMessages[idx] = ChatMessage(
|
||||||
|
id: messageId,
|
||||||
|
senderType: old.senderType,
|
||||||
|
content: old.content,
|
||||||
|
type: old.type,
|
||||||
|
status: status,
|
||||||
|
createdAt: old.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
emit(current.copyWith(messages: updatedMessages));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'message_status':
|
||||||
|
final messageIds = (data['message_ids'] as List<dynamic>).cast<String>();
|
||||||
|
final status = data['status'] as String;
|
||||||
|
final updatedMessages = current.messages.map((m) {
|
||||||
|
if (messageIds.contains(m.id)) {
|
||||||
|
return m.copyWith(status: status);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}).toList();
|
||||||
|
emit(current.copyWith(messages: updatedMessages));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'typing':
|
||||||
|
emit(current.copyWith(isOtherTyping: true));
|
||||||
|
_typingTimer?.cancel();
|
||||||
|
_typingTimer = Timer(const Duration(seconds: 3), () {
|
||||||
|
if (state is ChatConnected) {
|
||||||
|
emit((state as ChatConnected).copyWith(isOtherTyping: false));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'session_timer':
|
||||||
|
final remaining = data['remaining_seconds'] as int?;
|
||||||
|
emit(current.copyWith(remainingSeconds: remaining));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'session_expired':
|
||||||
|
emit(current.copyWith(sessionExpired: true));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'session_paused':
|
||||||
|
emit(current.copyWith(sessionPaused: true));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'session_resumed':
|
||||||
|
emit(current.copyWith(sessionPaused: false, sessionExpired: false));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'session_closing':
|
||||||
|
emit(current.copyWith(sessionClosing: true));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'extension_response':
|
||||||
|
emit(current.copyWith(extensionResponse: data));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'session_completed':
|
||||||
|
_cleanup();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
// Keep connected but show error
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onConnectionError(_ConnectionError event, Emitter<ChatState> emit) {
|
||||||
|
// Could implement reconnection logic here
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cleanup() {
|
||||||
|
_wsSubscription?.cancel();
|
||||||
|
_wsSubscription = null;
|
||||||
|
_channel?.sink.close();
|
||||||
|
_channel = null;
|
||||||
|
_typingTimer?.cancel();
|
||||||
|
_typingTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_cleanup();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
87
client_app/lib/core/chat/chat_opening_bloc.dart
Normal file
87
client_app/lib/core/chat/chat_opening_bloc.dart
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../api/api_client.dart';
|
||||||
|
|
||||||
|
// Events
|
||||||
|
abstract class ChatOpeningEvent extends Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoadPricing extends ChatOpeningEvent {}
|
||||||
|
|
||||||
|
// States
|
||||||
|
abstract class ChatOpeningState extends Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class PricingInitial extends ChatOpeningState {}
|
||||||
|
class PricingLoading extends ChatOpeningState {}
|
||||||
|
|
||||||
|
class PricingLoaded extends ChatOpeningState {
|
||||||
|
final List<PriceTier> tiers;
|
||||||
|
final bool freeTrialEligible;
|
||||||
|
final int freeTrialDurationMinutes;
|
||||||
|
|
||||||
|
PricingLoaded({
|
||||||
|
required this.tiers,
|
||||||
|
required this.freeTrialEligible,
|
||||||
|
this.freeTrialDurationMinutes = 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [tiers, freeTrialEligible, freeTrialDurationMinutes];
|
||||||
|
}
|
||||||
|
|
||||||
|
class PricingError extends ChatOpeningState {
|
||||||
|
final String message;
|
||||||
|
PricingError(this.message);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [message];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model
|
||||||
|
class PriceTier {
|
||||||
|
final int durationMinutes;
|
||||||
|
final int price;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
PriceTier({required this.durationMinutes, required this.price, required this.label});
|
||||||
|
|
||||||
|
factory PriceTier.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PriceTier(
|
||||||
|
durationMinutes: json['duration_minutes'] as int,
|
||||||
|
price: json['price'] as int,
|
||||||
|
label: json['label'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloc
|
||||||
|
class ChatOpeningBloc extends Bloc<ChatOpeningEvent, ChatOpeningState> {
|
||||||
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
ChatOpeningBloc({required this.apiClient}) : super(PricingInitial()) {
|
||||||
|
on<LoadPricing>(_onLoadPricing);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLoadPricing(LoadPricing event, Emitter<ChatOpeningState> emit) async {
|
||||||
|
emit(PricingLoading());
|
||||||
|
try {
|
||||||
|
final response = await apiClient.get('/api/client/chat/pricing');
|
||||||
|
final data = response['data'] as Map<String, dynamic>;
|
||||||
|
final tiersJson = data['tiers'] as List<dynamic>;
|
||||||
|
final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map<String, dynamic>)).toList();
|
||||||
|
final freeTrial = data['free_trial'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
emit(PricingLoaded(
|
||||||
|
tiers: tiers,
|
||||||
|
freeTrialEligible: freeTrial['eligible'] as bool? ?? false,
|
||||||
|
freeTrialDurationMinutes: freeTrial['duration_minutes'] as int? ?? 5,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
emit(PricingError('Gagal memuat harga. Coba lagi.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
client_app/lib/core/chat/session_closure_bloc.dart
Normal file
90
client_app/lib/core/chat/session_closure_bloc.dart
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../api/api_client.dart';
|
||||||
|
|
||||||
|
// Events
|
||||||
|
abstract class SessionClosureEvent extends Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class RequestExtension extends SessionClosureEvent {
|
||||||
|
final String sessionId;
|
||||||
|
final int durationMinutes;
|
||||||
|
final int price;
|
||||||
|
RequestExtension({required this.sessionId, required this.durationMinutes, required this.price});
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [sessionId, durationMinutes, price];
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeclineExtension extends SessionClosureEvent {}
|
||||||
|
|
||||||
|
class SubmitGoodbye extends SessionClosureEvent {
|
||||||
|
final String sessionId;
|
||||||
|
final String message;
|
||||||
|
SubmitGoodbye({required this.sessionId, required this.message});
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [sessionId, message];
|
||||||
|
}
|
||||||
|
|
||||||
|
// States
|
||||||
|
abstract class SessionClosureState extends Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClosureInitial extends SessionClosureState {}
|
||||||
|
class ExtendingWaitingMitra extends SessionClosureState {}
|
||||||
|
|
||||||
|
class ClosureShowGoodbye extends SessionClosureState {}
|
||||||
|
|
||||||
|
class ClosureSubmitting extends SessionClosureState {}
|
||||||
|
|
||||||
|
class ClosureComplete extends SessionClosureState {}
|
||||||
|
|
||||||
|
class ClosureError extends SessionClosureState {
|
||||||
|
final String message;
|
||||||
|
ClosureError(this.message);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [message];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloc
|
||||||
|
class SessionClosureBloc extends Bloc<SessionClosureEvent, SessionClosureState> {
|
||||||
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
SessionClosureBloc({required this.apiClient}) : super(ClosureInitial()) {
|
||||||
|
on<RequestExtension>(_onRequestExtension);
|
||||||
|
on<DeclineExtension>(_onDeclineExtension);
|
||||||
|
on<SubmitGoodbye>(_onSubmitGoodbye);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onRequestExtension(RequestExtension event, Emitter<SessionClosureState> emit) async {
|
||||||
|
emit(ExtendingWaitingMitra());
|
||||||
|
try {
|
||||||
|
await apiClient.post('/api/client/chat/session/${event.sessionId}/extend', data: {
|
||||||
|
'duration_minutes': event.durationMinutes,
|
||||||
|
'price': event.price,
|
||||||
|
});
|
||||||
|
// Response will come via WebSocket (ChatBloc handles it)
|
||||||
|
} catch (e) {
|
||||||
|
emit(ClosureError('Gagal meminta perpanjangan.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDeclineExtension(DeclineExtension event, Emitter<SessionClosureState> emit) {
|
||||||
|
emit(ClosureShowGoodbye());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSubmitGoodbye(SubmitGoodbye event, Emitter<SessionClosureState> emit) async {
|
||||||
|
emit(ClosureSubmitting());
|
||||||
|
try {
|
||||||
|
await apiClient.post('/api/shared/sessions/${event.sessionId}/close-message', data: {
|
||||||
|
'message': event.message,
|
||||||
|
});
|
||||||
|
emit(ClosureComplete());
|
||||||
|
} catch (e) {
|
||||||
|
emit(ClosureError('Gagal mengirim pesan penutup.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,16 @@ abstract class PairingEvent extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class RequestPairing extends PairingEvent {}
|
class RequestPairing extends PairingEvent {}
|
||||||
|
|
||||||
|
class RequestPairingWithTier extends PairingEvent {
|
||||||
|
final int? durationMinutes;
|
||||||
|
final int? price;
|
||||||
|
final bool isFreeTrial;
|
||||||
|
RequestPairingWithTier({this.durationMinutes, this.price, this.isFreeTrial = false});
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [durationMinutes, price, isFreeTrial];
|
||||||
|
}
|
||||||
|
|
||||||
class CancelPairing extends PairingEvent {}
|
class CancelPairing extends PairingEvent {}
|
||||||
|
|
||||||
class _PairingStatusUpdate extends PairingEvent {
|
class _PairingStatusUpdate extends PairingEvent {
|
||||||
@@ -71,29 +81,42 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
|||||||
|
|
||||||
PairingBloc({required this.apiClient}) : super(PairingInitial()) {
|
PairingBloc({required this.apiClient}) : super(PairingInitial()) {
|
||||||
on<RequestPairing>(_onRequestPairing);
|
on<RequestPairing>(_onRequestPairing);
|
||||||
|
on<RequestPairingWithTier>(_onRequestPairingWithTier);
|
||||||
on<CancelPairing>(_onCancelPairing);
|
on<CancelPairing>(_onCancelPairing);
|
||||||
on<_PairingStatusUpdate>(_onStatusUpdate);
|
on<_PairingStatusUpdate>(_onStatusUpdate);
|
||||||
on<_PairingTimeout>(_onTimeout);
|
on<_PairingTimeout>(_onTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onRequestPairing(RequestPairing event, Emitter<PairingState> emit) async {
|
Future<void> _onRequestPairing(RequestPairing event, Emitter<PairingState> emit) async {
|
||||||
// Reset to initial so BlocListener can detect new errors
|
await _doPairingRequest(emit, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onRequestPairingWithTier(RequestPairingWithTier event, Emitter<PairingState> emit) async {
|
||||||
|
final body = <String, dynamic>{};
|
||||||
|
if (event.isFreeTrial) {
|
||||||
|
body['is_free_trial'] = true;
|
||||||
|
} else {
|
||||||
|
body['duration_minutes'] = event.durationMinutes;
|
||||||
|
body['price'] = event.price;
|
||||||
|
}
|
||||||
|
await _doPairingRequest(emit, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _doPairingRequest(Emitter<PairingState> emit, Map<String, dynamic> body) async {
|
||||||
if (state is! PairingInitial) {
|
if (state is! PairingInitial) {
|
||||||
emit(PairingInitial());
|
emit(PairingInitial());
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final response = await apiClient.post('/api/client/chat/request');
|
final response = await apiClient.post('/api/client/chat/request', data: body);
|
||||||
final data = response['data'] as Map<String, dynamic>;
|
final data = response['data'] as Map<String, dynamic>;
|
||||||
final sessionId = data['id'] as String;
|
final sessionId = data['id'] as String;
|
||||||
|
|
||||||
emit(PairingSearching(sessionId));
|
emit(PairingSearching(sessionId));
|
||||||
|
|
||||||
// Start 60s local timeout as a safety net
|
|
||||||
_timeoutTimer = Timer(const Duration(seconds: 60), () {
|
_timeoutTimer = Timer(const Duration(seconds: 60), () {
|
||||||
add(_PairingTimeout());
|
add(_PairingTimeout());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen to SSE for status updates
|
|
||||||
_listenToSSE(sessionId);
|
_listenToSSE(sessionId);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
final code = e.response?.data?['error']?['code'];
|
final code = e.response?.data?['error']?['code'];
|
||||||
@@ -101,6 +124,8 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
|||||||
emit(PairingNoBestie());
|
emit(PairingNoBestie());
|
||||||
} else if (code == 'ALREADY_ACTIVE') {
|
} else if (code == 'ALREADY_ACTIVE') {
|
||||||
emit(PairingError('Kamu sudah memiliki sesi aktif.'));
|
emit(PairingError('Kamu sudah memiliki sesi aktif.'));
|
||||||
|
} else if (code == 'FREE_TRIAL_INELIGIBLE') {
|
||||||
|
emit(PairingError('Kamu tidak memenuhi syarat untuk free trial.'));
|
||||||
} else {
|
} else {
|
||||||
emit(PairingError('Gagal memulai. Coba lagi.'));
|
emit(PairingError('Gagal memulai. Coba lagi.'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/api/api_client.dart';
|
||||||
|
|
||||||
|
class ChatHistoryScreen extends StatefulWidget {
|
||||||
|
const ChatHistoryScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChatHistoryScreen> createState() => _ChatHistoryScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatHistoryScreenState extends State<ChatHistoryScreen> {
|
||||||
|
List<Map<String, dynamic>> _sessions = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadHistory() async {
|
||||||
|
try {
|
||||||
|
final api = context.read<ApiClient>();
|
||||||
|
final response = await api.get('/api/client/chat/history');
|
||||||
|
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
|
||||||
|
setState(() {
|
||||||
|
_sessions = items;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Riwayat Chat')),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _sessions.isEmpty
|
||||||
|
? const Center(child: Text('Belum ada riwayat chat'))
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: _sessions.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final s = _sessions[index];
|
||||||
|
final mitraName = s['mitra_display_name'] as String? ?? 'Bestie';
|
||||||
|
final endedAt = s['ended_at'] != null
|
||||||
|
? DateTime.parse(s['ended_at'] as String).toLocal()
|
||||||
|
: null;
|
||||||
|
final duration = s['duration_minutes'] as int?;
|
||||||
|
final closureMsg = s['customer_closure_message'] as String?;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: const CircleAvatar(child: Icon(Icons.person)),
|
||||||
|
title: Text(mitraName),
|
||||||
|
subtitle: Text([
|
||||||
|
if (endedAt != null) '${endedAt.day}/${endedAt.month}/${endedAt.year}',
|
||||||
|
if (duration != null) '$duration menit',
|
||||||
|
if (closureMsg != null) '"$closureMsg"',
|
||||||
|
].join(' - ')),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => context.push('/chat/history/${s['id']}'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
352
client_app/lib/features/chat/screens/chat_screen.dart
Normal file
352
client_app/lib/features/chat/screens/chat_screen.dart
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/chat/chat_bloc.dart';
|
||||||
|
import '../../../core/chat/session_closure_bloc.dart';
|
||||||
|
import '../widgets/pricing_bottom_sheet.dart';
|
||||||
|
|
||||||
|
class ChatScreen extends StatefulWidget {
|
||||||
|
final String sessionId;
|
||||||
|
final String mitraName;
|
||||||
|
|
||||||
|
const ChatScreen({super.key, required this.sessionId, required this.mitraName});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChatScreen> createState() => _ChatScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatScreenState extends State<ChatScreen> {
|
||||||
|
final _messageController = TextEditingController();
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
Timer? _typingThrottle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_messageController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
_typingThrottle?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToBottom() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTextChanged(String text) {
|
||||||
|
if (_typingThrottle?.isActive ?? false) return;
|
||||||
|
context.read<ChatBloc>().add(SendTyping());
|
||||||
|
_typingThrottle = Timer(const Duration(seconds: 2), () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendMessage() {
|
||||||
|
final text = _messageController.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
context.read<ChatBloc>().add(SendMessage(text));
|
||||||
|
_messageController.clear();
|
||||||
|
_scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MultiBlocListener(
|
||||||
|
listeners: [
|
||||||
|
BlocListener<ChatBloc, ChatState>(
|
||||||
|
listenWhen: (prev, curr) {
|
||||||
|
if (prev is ChatConnected && curr is ChatConnected) {
|
||||||
|
return prev.sessionExpired != curr.sessionExpired ||
|
||||||
|
prev.sessionClosing != curr.sessionClosing ||
|
||||||
|
prev.messages.length != curr.messages.length;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state is ChatConnected) {
|
||||||
|
if (state.sessionClosing) {
|
||||||
|
context.read<SessionClosureBloc>().add(DeclineExtension());
|
||||||
|
}
|
||||||
|
_scrollToBottom();
|
||||||
|
// Auto-mark received messages as read
|
||||||
|
final unread = state.messages
|
||||||
|
.where((m) => m.senderType == 'mitra' && m.status != 'read')
|
||||||
|
.map((m) => m.id)
|
||||||
|
.toList();
|
||||||
|
if (unread.isNotEmpty) {
|
||||||
|
context.read<ChatBloc>().add(MarkMessagesRead(unread));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
BlocListener<SessionClosureBloc, SessionClosureState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state is ClosureComplete) {
|
||||||
|
context.go('/home');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(widget.mitraName),
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
actions: [
|
||||||
|
BlocBuilder<ChatBloc, ChatState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is ChatConnected && state.remainingSeconds != null) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'${state.remainingSeconds}s',
|
||||||
|
style: TextStyle(
|
||||||
|
color: state.remainingSeconds! < 30 ? Colors.red : null,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: BlocBuilder<ChatBloc, ChatState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is ChatConnecting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (state is ChatError) {
|
||||||
|
return Center(child: Text(state.message));
|
||||||
|
}
|
||||||
|
if (state is ChatConnected) {
|
||||||
|
return _buildChatBody(context, state);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChatBody(BuildContext context, ChatConnected state) {
|
||||||
|
// Show session expired dialog
|
||||||
|
if (state.sessionExpired) {
|
||||||
|
return _buildExpiredView(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show goodbye input
|
||||||
|
final closureState = context.watch<SessionClosureBloc>().state;
|
||||||
|
if (closureState is ClosureShowGoodbye || closureState is ClosureSubmitting) {
|
||||||
|
return _buildGoodbyeView(context, closureState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.sessionPaused) {
|
||||||
|
return _buildPausedView();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: state.messages.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final msg = state.messages[index];
|
||||||
|
final isMe = msg.senderType == 'customer';
|
||||||
|
return _buildMessageBubble(msg, isMe);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state.isOtherTyping)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text('Bestie sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildInputBar(context, state),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageBubble(ChatMessage msg, bool isMe) {
|
||||||
|
return Align(
|
||||||
|
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isMe ? Colors.blue.shade100 : Colors.grey.shade200,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(msg.content, style: const TextStyle(fontSize: 15)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${msg.createdAt.hour.toString().padLeft(2, '0')}:${msg.createdAt.minute.toString().padLeft(2, '0')}',
|
||||||
|
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
||||||
|
),
|
||||||
|
if (isMe) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_buildStatusIcon(msg.status),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusIcon(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'sending':
|
||||||
|
return const Icon(Icons.access_time, size: 14, color: Colors.grey);
|
||||||
|
case 'sent':
|
||||||
|
return const Icon(Icons.check, size: 14, color: Colors.grey);
|
||||||
|
case 'delivered':
|
||||||
|
return const Icon(Icons.done_all, size: 14, color: Colors.grey);
|
||||||
|
case 'read':
|
||||||
|
return const Icon(Icons.done_all, size: 14, color: Colors.blue);
|
||||||
|
default:
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInputBar(BuildContext context, ChatConnected state) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _messageController,
|
||||||
|
onChanged: _onTextChanged,
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
onSubmitted: (_) => _sendMessage(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Ketik pesan...',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.send, color: Colors.blue),
|
||||||
|
onPressed: _sendMessage,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildExpiredView(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.timer_off, size: 64, color: Colors.orange),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Waktu sesi habis', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Apakah kamu ingin memperpanjang sesi?', textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => PricingBottomSheet.show(context),
|
||||||
|
child: const Text('Perpanjang Sesi'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.read<SessionClosureBloc>().add(DeclineExtension()),
|
||||||
|
child: const Text('Tidak, akhiri sesi'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGoodbyeView(BuildContext context, SessionClosureState closureState) {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.waving_hand, size: 64, color: Colors.amber),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Tuliskan pesan terakhirmu untuk Bestie', textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Terima kasih, Bestie...',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: closureState is ClosureSubmitting
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
final text = controller.text.trim();
|
||||||
|
if (text.isNotEmpty) {
|
||||||
|
context.read<SessionClosureBloc>().add(
|
||||||
|
SubmitGoodbye(sessionId: widget.sessionId, message: text),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: closureState is ClosureSubmitting
|
||||||
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Text('Kirim & Selesai'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPausedView() {
|
||||||
|
return const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 24),
|
||||||
|
Text('Menunggu konfirmasi Bestie...', style: TextStyle(fontSize: 18)),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text('Chat dijeda sementara', style: TextStyle(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../core/api/api_client.dart';
|
||||||
|
|
||||||
|
class ChatTranscriptScreen extends StatefulWidget {
|
||||||
|
final String sessionId;
|
||||||
|
|
||||||
|
const ChatTranscriptScreen({super.key, required this.sessionId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChatTranscriptScreen> createState() => _ChatTranscriptScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatTranscriptScreenState extends State<ChatTranscriptScreen> {
|
||||||
|
List<Map<String, dynamic>> _messages = [];
|
||||||
|
List<Map<String, dynamic>> _closures = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadTranscript();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadTranscript() async {
|
||||||
|
try {
|
||||||
|
final api = context.read<ApiClient>();
|
||||||
|
final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript');
|
||||||
|
final data = response['data'] as Map<String, dynamic>;
|
||||||
|
setState(() {
|
||||||
|
_messages = (data['messages'] as List<dynamic>).cast<Map<String, dynamic>>();
|
||||||
|
_closures = (data['closures'] as List<dynamic>).cast<Map<String, dynamic>>();
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Transkrip Chat')),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
..._messages.map((m) {
|
||||||
|
final isMe = m['sender_type'] == 'customer';
|
||||||
|
final time = DateTime.parse(m['created_at'] as String).toLocal();
|
||||||
|
return Align(
|
||||||
|
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isMe ? Colors.blue.shade100 : Colors.grey.shade200,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(m['content'] as String, style: const TextStyle(fontSize: 15)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}',
|
||||||
|
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
if (_closures.isNotEmpty) ...[
|
||||||
|
const Divider(height: 32),
|
||||||
|
const Text('Pesan Penutup', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._closures.map((c) => Card(
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(c['user_type'] == 'customer' ? 'Kamu' : 'Bestie'),
|
||||||
|
subtitle: Text(c['message'] as String),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart
Normal file
119
client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../core/api/api_client.dart';
|
||||||
|
import '../../../core/chat/chat_opening_bloc.dart';
|
||||||
|
import '../../../core/pairing/pairing_bloc.dart';
|
||||||
|
|
||||||
|
class PricingBottomSheet extends StatelessWidget {
|
||||||
|
const PricingBottomSheet({super.key});
|
||||||
|
|
||||||
|
static Future<void> show(BuildContext context) {
|
||||||
|
return showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => BlocProvider(
|
||||||
|
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
|
||||||
|
child: const PricingBottomSheet(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatPrice(int price) {
|
||||||
|
final str = price.toString();
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (var i = 0; i < str.length; i++) {
|
||||||
|
if (i > 0 && (str.length - i) % 3 == 0) buffer.write('.');
|
||||||
|
buffer.write(str[i]);
|
||||||
|
}
|
||||||
|
return 'Rp $buffer';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<ChatOpeningBloc, ChatOpeningState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is PricingLoading || state is PricingInitial) {
|
||||||
|
return const SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state is PricingError) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: Center(child: Text(state.message)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state is PricingLoaded) {
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
initialChildSize: 0.6,
|
||||||
|
minChildSize: 0.4,
|
||||||
|
maxChildSize: 0.8,
|
||||||
|
expand: false,
|
||||||
|
builder: (_, scrollController) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: ListView(
|
||||||
|
controller: scrollController,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Pilih Durasi Curhat',
|
||||||
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (state.freeTrialEligible) ...[
|
||||||
|
Card(
|
||||||
|
color: Colors.green.shade50,
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.card_giftcard, color: Colors.green),
|
||||||
|
title: Text('Free Trial (${state.freeTrialDurationMinutes} Menit)'),
|
||||||
|
subtitle: const Text('Gratis untuk pertama kali!'),
|
||||||
|
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_startPairing(context, isFreeTrial: true);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
|
],
|
||||||
|
...state.tiers.map((tier) => Card(
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(tier.label),
|
||||||
|
trailing: Text(
|
||||||
|
_formatPrice(tier.price),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_startPairing(
|
||||||
|
context,
|
||||||
|
durationMinutes: tier.durationMinutes,
|
||||||
|
price: tier.price,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startPairing(BuildContext context, {bool isFreeTrial = false, int? durationMinutes, int? price}) {
|
||||||
|
context.read<PairingBloc>().add(RequestPairingWithTier(
|
||||||
|
durationMinutes: durationMinutes,
|
||||||
|
price: price,
|
||||||
|
isFreeTrial: isFreeTrial,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:go_router/go_router.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';
|
import '../../core/pairing/pairing_bloc.dart';
|
||||||
|
import '../chat/widgets/pricing_bottom_sheet.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
@@ -33,6 +34,10 @@ class HomeScreen extends StatelessWidget {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Halo Bestie'),
|
title: const Text('Halo Bestie'),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.history),
|
||||||
|
onPressed: () => context.push('/chat/history'),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()),
|
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()),
|
||||||
@@ -51,7 +56,7 @@ class HomeScreen extends StatelessWidget {
|
|||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
||||||
),
|
),
|
||||||
onPressed: () => context.read<PairingBloc>().add(RequestPairing()),
|
onPressed: () => PricingBottomSheet.show(context),
|
||||||
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
|
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
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 'package:go_router/go_router.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/chat/chat_bloc.dart';
|
||||||
|
import 'core/chat/session_closure_bloc.dart';
|
||||||
import 'core/pairing/pairing_bloc.dart';
|
import 'core/pairing/pairing_bloc.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
import 'router.dart';
|
import 'router.dart';
|
||||||
@@ -11,6 +14,11 @@ import 'router.dart';
|
|||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||||
|
|
||||||
|
// Request notification permission
|
||||||
|
final messaging = FirebaseMessaging.instance;
|
||||||
|
await messaging.requestPermission();
|
||||||
|
|
||||||
runApp(const App());
|
runApp(const App());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +39,21 @@ class _AppState extends State<App> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted());
|
_authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted());
|
||||||
_router = buildRouter(_authBloc);
|
_router = buildRouter(_authBloc);
|
||||||
|
_registerFcmToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _registerFcmToken() async {
|
||||||
|
// Listen for auth state, then register token
|
||||||
|
_authBloc.stream.listen((state) async {
|
||||||
|
if (state is AuthAuthenticated || state is AuthAnonymous) {
|
||||||
|
try {
|
||||||
|
final token = await FirebaseMessaging.instance.getToken();
|
||||||
|
if (token != null) {
|
||||||
|
await _apiClient.post('/api/shared/device-token', data: {'token': token});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -46,6 +69,8 @@ class _AppState extends State<App> {
|
|||||||
providers: [
|
providers: [
|
||||||
BlocProvider.value(value: _authBloc),
|
BlocProvider.value(value: _authBloc),
|
||||||
BlocProvider(create: (_) => PairingBloc(apiClient: _apiClient)),
|
BlocProvider(create: (_) => PairingBloc(apiClient: _apiClient)),
|
||||||
|
BlocProvider(create: (_) => ChatBloc(apiClient: _apiClient)),
|
||||||
|
BlocProvider(create: (_) => SessionClosureBloc(apiClient: _apiClient)),
|
||||||
RepositoryProvider.value(value: _apiClient),
|
RepositoryProvider.value(value: _apiClient),
|
||||||
],
|
],
|
||||||
child: MaterialApp.router(
|
child: MaterialApp.router(
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import 'features/home/home_screen.dart';
|
|||||||
import 'features/chat/screens/searching_screen.dart';
|
import 'features/chat/screens/searching_screen.dart';
|
||||||
import 'features/chat/screens/bestie_found_screen.dart';
|
import 'features/chat/screens/bestie_found_screen.dart';
|
||||||
import 'features/chat/screens/no_bestie_screen.dart';
|
import 'features/chat/screens/no_bestie_screen.dart';
|
||||||
import 'features/chat/screens/session_active_screen.dart';
|
import 'features/chat/screens/chat_screen.dart';
|
||||||
|
import 'features/chat/screens/chat_history_screen.dart';
|
||||||
|
import 'features/chat/screens/chat_transcript_screen.dart';
|
||||||
|
|
||||||
/// Converts a BLoC stream into a ChangeNotifier for GoRouter's refreshListenable.
|
/// Converts a BLoC stream into a ChangeNotifier for GoRouter's refreshListenable.
|
||||||
class _BlocRefreshNotifier extends ChangeNotifier {
|
class _BlocRefreshNotifier extends ChangeNotifier {
|
||||||
@@ -64,11 +66,16 @@ GoRouter buildRouter(AuthBloc authBloc) {
|
|||||||
}),
|
}),
|
||||||
GoRoute(path: '/chat/no-bestie', builder: (_, __) => const NoBestieScreen()),
|
GoRoute(path: '/chat/no-bestie', builder: (_, __) => const NoBestieScreen()),
|
||||||
GoRoute(path: '/chat/session/:sessionId', builder: (context, state) {
|
GoRoute(path: '/chat/session/:sessionId', builder: (context, state) {
|
||||||
return SessionActiveScreen(
|
final extra = state.extra as Map<String, dynamic>?;
|
||||||
|
return ChatScreen(
|
||||||
sessionId: state.pathParameters['sessionId']!,
|
sessionId: state.pathParameters['sessionId']!,
|
||||||
mitraName: state.extra as String? ?? 'Bestie',
|
mitraName: extra?['mitraName'] as String? ?? 'Bestie',
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
GoRoute(path: '/chat/history', builder: (_, __) => const ChatHistoryScreen()),
|
||||||
|
GoRoute(path: '/chat/history/:sessionId', builder: (context, state) {
|
||||||
|
return ChatTranscriptScreen(sessionId: state.pathParameters['sessionId']!);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Foundation
|
|||||||
|
|
||||||
import firebase_auth
|
import firebase_auth
|
||||||
import firebase_core
|
import firebase_core
|
||||||
|
import firebase_messaging
|
||||||
import google_sign_in_ios
|
import google_sign_in_ios
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sign_in_with_apple
|
import sign_in_with_apple
|
||||||
@@ -14,6 +15,7 @@ import sign_in_with_apple
|
|||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
|
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
|
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
crypto:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -153,6 +161,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.17.5"
|
version: "2.17.5"
|
||||||
|
firebase_messaging:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: firebase_messaging
|
||||||
|
sha256: a1662cc95d9750a324ad9df349b873360af6f11414902021f130c68ec02267c4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "14.9.4"
|
||||||
|
firebase_messaging_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_messaging_platform_interface
|
||||||
|
sha256: "87c4a922cb6f811cfb7a889bdbb3622702443c52a0271636cbc90d813ceac147"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.37"
|
||||||
|
firebase_messaging_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_messaging_web
|
||||||
|
sha256: "0d34dca01a7b103ed7f20138bffbb28eb0e61a677bf9e78a028a932e2c7322d5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.8.7"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -557,6 +589,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.1"
|
version: "0.5.1"
|
||||||
|
web_socket_channel:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: web_socket_channel
|
||||||
|
sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.5"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -14,13 +14,15 @@ dependencies:
|
|||||||
# Firebase
|
# Firebase
|
||||||
firebase_core: ^2.27.1
|
firebase_core: ^2.27.1
|
||||||
firebase_auth: ^4.18.0
|
firebase_auth: ^4.18.0
|
||||||
|
firebase_messaging: ^14.7.15
|
||||||
|
|
||||||
# Social login
|
# Social login
|
||||||
google_sign_in: ^6.2.1
|
google_sign_in: ^6.2.1
|
||||||
sign_in_with_apple: ^6.1.0
|
sign_in_with_apple: ^6.1.0
|
||||||
|
|
||||||
# HTTP
|
# HTTP & WebSocket
|
||||||
dio: ^5.4.3
|
dio: ^5.4.3
|
||||||
|
web_socket_channel: ^2.4.5
|
||||||
|
|
||||||
# State management
|
# State management
|
||||||
flutter_bloc: ^8.1.5
|
flutter_bloc: ^8.1.5
|
||||||
|
|||||||
@@ -21,6 +21,37 @@ const updateMaxCustomersConfig = async (max_customers_per_mitra) => {
|
|||||||
return res.data.data
|
return res.data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 3 config fetchers
|
||||||
|
const fetchFreeTrialConfig = async () => {
|
||||||
|
const res = await apiClient.get('/internal/config/free-trial')
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFreeTrialConfig = async (data) => {
|
||||||
|
const res = await apiClient.patch('/internal/config/free-trial', data)
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchExtensionTimeoutConfig = async () => {
|
||||||
|
const res = await apiClient.get('/internal/config/extension-timeout')
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateExtensionTimeoutConfig = async (extension_timeout_seconds) => {
|
||||||
|
const res = await apiClient.patch('/internal/config/extension-timeout', { extension_timeout_seconds })
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchEarlyEndConfig = async () => {
|
||||||
|
const res = await apiClient.get('/internal/config/early-end')
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateEarlyEndConfig = async (data) => {
|
||||||
|
const res = await apiClient.patch('/internal/config/early-end', data)
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
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 })
|
||||||
@@ -40,7 +71,37 @@ export default function SettingsPage() {
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-max-customers'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-max-customers'] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading || maxLoading) return <div>Loading...</div>
|
// Phase 3: Free Trial
|
||||||
|
const { data: ftData, isLoading: ftLoading } = useQuery({
|
||||||
|
queryKey: ['config-free-trial'],
|
||||||
|
queryFn: fetchFreeTrialConfig,
|
||||||
|
})
|
||||||
|
const ftMutation = useMutation({
|
||||||
|
mutationFn: updateFreeTrialConfig,
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-free-trial'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Phase 3: Extension Timeout
|
||||||
|
const { data: etData, isLoading: etLoading } = useQuery({
|
||||||
|
queryKey: ['config-extension-timeout'],
|
||||||
|
queryFn: fetchExtensionTimeoutConfig,
|
||||||
|
})
|
||||||
|
const etMutation = useMutation({
|
||||||
|
mutationFn: updateExtensionTimeoutConfig,
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-extension-timeout'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Phase 3: Early End
|
||||||
|
const { data: eeData, isLoading: eeLoading } = useQuery({
|
||||||
|
queryKey: ['config-early-end'],
|
||||||
|
queryFn: fetchEarlyEndConfig,
|
||||||
|
})
|
||||||
|
const eeMutation = useMutation({
|
||||||
|
mutationFn: updateEarlyEndConfig,
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-early-end'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading || maxLoading || ftLoading || etLoading || eeLoading) return <div>Loading...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -80,6 +141,80 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{maxMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
{maxMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: 24 }}>
|
||||||
|
<h2>Free Trial</h2>
|
||||||
|
<p>Aktifkan free trial untuk customer baru yang belum pernah bertransaksi.</p>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ftData?.enabled ?? false}
|
||||||
|
onChange={e => ftMutation.mutate({ enabled: e.target.checked })}
|
||||||
|
disabled={ftMutation.isPending}
|
||||||
|
/>
|
||||||
|
Aktifkan Free Trial
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<label>Durasi:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={ftData?.duration_minutes ?? 5}
|
||||||
|
onChange={e => {
|
||||||
|
const val = parseInt(e.target.value, 10)
|
||||||
|
if (val >= 1) ftMutation.mutate({ duration_minutes: val })
|
||||||
|
}}
|
||||||
|
disabled={ftMutation.isPending}
|
||||||
|
style={{ width: 80 }}
|
||||||
|
/>
|
||||||
|
<span>menit</span>
|
||||||
|
</div>
|
||||||
|
{ftMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: 24 }}>
|
||||||
|
<h2>Extension Timeout</h2>
|
||||||
|
<p>Waktu tunggu untuk customer memutuskan dan mitra mengkonfirmasi perpanjangan sesi.</p>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
value={etData?.extension_timeout_seconds ?? 60}
|
||||||
|
onChange={e => {
|
||||||
|
const val = parseInt(e.target.value, 10)
|
||||||
|
if (val >= 10) etMutation.mutate(val)
|
||||||
|
}}
|
||||||
|
disabled={etMutation.isPending}
|
||||||
|
style={{ width: 80 }}
|
||||||
|
/>
|
||||||
|
<span>detik</span>
|
||||||
|
</div>
|
||||||
|
{etMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: 24 }}>
|
||||||
|
<h2>Akhiri Sesi Lebih Awal</h2>
|
||||||
|
<p>Izinkan mitra dan/atau customer untuk mengakhiri sesi sebelum waktu habis.</p>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={eeData?.mitra_enabled ?? false}
|
||||||
|
onChange={e => eeMutation.mutate({ mitra_enabled: e.target.checked })}
|
||||||
|
disabled={eeMutation.isPending}
|
||||||
|
/>
|
||||||
|
Izinkan Mitra mengakhiri lebih awal
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={eeData?.customer_enabled ?? false}
|
||||||
|
onChange={e => eeMutation.mutate({ customer_enabled: e.target.checked })}
|
||||||
|
disabled={eeMutation.isPending}
|
||||||
|
/>
|
||||||
|
Izinkan Customer mengakhiri lebih awal
|
||||||
|
</label>
|
||||||
|
{eeMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
84
mitra_app/lib/core/chat/extension_bloc.dart
Normal file
84
mitra_app/lib/core/chat/extension_bloc.dart
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../api/api_client.dart';
|
||||||
|
|
||||||
|
// Events
|
||||||
|
abstract class ExtensionEvent extends Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class RespondToExtension extends ExtensionEvent {
|
||||||
|
final String sessionId;
|
||||||
|
final String extensionId;
|
||||||
|
final bool accepted;
|
||||||
|
RespondToExtension({required this.sessionId, required this.extensionId, required this.accepted});
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [sessionId, extensionId, accepted];
|
||||||
|
}
|
||||||
|
|
||||||
|
class SubmitGoodbye extends ExtensionEvent {
|
||||||
|
final String sessionId;
|
||||||
|
final String message;
|
||||||
|
SubmitGoodbye({required this.sessionId, required this.message});
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [sessionId, message];
|
||||||
|
}
|
||||||
|
|
||||||
|
// States
|
||||||
|
abstract class ExtensionState extends Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExtensionIdle extends ExtensionState {}
|
||||||
|
class ExtensionResponding extends ExtensionState {}
|
||||||
|
class ExtensionShowGoodbye extends ExtensionState {}
|
||||||
|
class ExtensionSubmitting extends ExtensionState {}
|
||||||
|
class ExtensionComplete extends ExtensionState {}
|
||||||
|
|
||||||
|
class ExtensionError extends ExtensionState {
|
||||||
|
final String message;
|
||||||
|
ExtensionError(this.message);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [message];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloc
|
||||||
|
class ExtensionBloc extends Bloc<ExtensionEvent, ExtensionState> {
|
||||||
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
ExtensionBloc({required this.apiClient}) : super(ExtensionIdle()) {
|
||||||
|
on<RespondToExtension>(_onRespond);
|
||||||
|
on<SubmitGoodbye>(_onSubmitGoodbye);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onRespond(RespondToExtension event, Emitter<ExtensionState> emit) async {
|
||||||
|
emit(ExtensionResponding());
|
||||||
|
try {
|
||||||
|
await apiClient.post('/api/mitra/chat-requests/sessions/${event.sessionId}/extend-response', data: {
|
||||||
|
'extension_id': event.extensionId,
|
||||||
|
'accepted': event.accepted,
|
||||||
|
});
|
||||||
|
if (!event.accepted) {
|
||||||
|
emit(ExtensionShowGoodbye());
|
||||||
|
} else {
|
||||||
|
emit(ExtensionIdle());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
emit(ExtensionError('Gagal merespon perpanjangan.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSubmitGoodbye(SubmitGoodbye event, Emitter<ExtensionState> emit) async {
|
||||||
|
emit(ExtensionSubmitting());
|
||||||
|
try {
|
||||||
|
await apiClient.post('/api/shared/sessions/${event.sessionId}/close-message', data: {
|
||||||
|
'message': event.message,
|
||||||
|
});
|
||||||
|
emit(ExtensionComplete());
|
||||||
|
} catch (e) {
|
||||||
|
emit(ExtensionError('Gagal mengirim pesan penutup.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
351
mitra_app/lib/core/chat/mitra_chat_bloc.dart
Normal file
351
mitra_app/lib/core/chat/mitra_chat_bloc.dart
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
import '../api/api_client.dart';
|
||||||
|
|
||||||
|
// Events
|
||||||
|
abstract class MitraChatEvent extends Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConnectChat extends MitraChatEvent {
|
||||||
|
final String sessionId;
|
||||||
|
ConnectChat(this.sessionId);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [sessionId];
|
||||||
|
}
|
||||||
|
|
||||||
|
class DisconnectChat extends MitraChatEvent {}
|
||||||
|
|
||||||
|
class SendMessage extends MitraChatEvent {
|
||||||
|
final String content;
|
||||||
|
SendMessage(this.content);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [content];
|
||||||
|
}
|
||||||
|
|
||||||
|
class SendTyping extends MitraChatEvent {}
|
||||||
|
|
||||||
|
class _MessageReceived extends MitraChatEvent {
|
||||||
|
final Map<String, dynamic> data;
|
||||||
|
_MessageReceived(this.data);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [data];
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConnectionError extends MitraChatEvent {}
|
||||||
|
|
||||||
|
class MarkMessagesDelivered extends MitraChatEvent {
|
||||||
|
final List<String> messageIds;
|
||||||
|
MarkMessagesDelivered(this.messageIds);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [messageIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
class MarkMessagesRead extends MitraChatEvent {
|
||||||
|
final List<String> messageIds;
|
||||||
|
MarkMessagesRead(this.messageIds);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [messageIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
// States
|
||||||
|
abstract class MitraChatState extends Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatInitial extends MitraChatState {}
|
||||||
|
class ChatConnecting extends MitraChatState {}
|
||||||
|
|
||||||
|
class ChatConnected extends MitraChatState {
|
||||||
|
final List<ChatMessage> messages;
|
||||||
|
final bool isOtherTyping;
|
||||||
|
final int? remainingSeconds;
|
||||||
|
final bool sessionExpired;
|
||||||
|
final bool sessionClosing;
|
||||||
|
final Map<String, dynamic>? extensionRequest;
|
||||||
|
|
||||||
|
ChatConnected({
|
||||||
|
required this.messages,
|
||||||
|
this.isOtherTyping = false,
|
||||||
|
this.remainingSeconds,
|
||||||
|
this.sessionExpired = false,
|
||||||
|
this.sessionClosing = false,
|
||||||
|
this.extensionRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
ChatConnected copyWith({
|
||||||
|
List<ChatMessage>? messages,
|
||||||
|
bool? isOtherTyping,
|
||||||
|
int? remainingSeconds,
|
||||||
|
bool? sessionExpired,
|
||||||
|
bool? sessionClosing,
|
||||||
|
Map<String, dynamic>? extensionRequest,
|
||||||
|
}) {
|
||||||
|
return ChatConnected(
|
||||||
|
messages: messages ?? this.messages,
|
||||||
|
isOtherTyping: isOtherTyping ?? this.isOtherTyping,
|
||||||
|
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||||
|
sessionExpired: sessionExpired ?? this.sessionExpired,
|
||||||
|
sessionClosing: sessionClosing ?? this.sessionClosing,
|
||||||
|
extensionRequest: extensionRequest ?? this.extensionRequest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [messages, isOtherTyping, remainingSeconds, sessionExpired, sessionClosing, extensionRequest];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatError extends MitraChatState {
|
||||||
|
final String message;
|
||||||
|
ChatError(this.message);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [message];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message model
|
||||||
|
class ChatMessage {
|
||||||
|
final String id;
|
||||||
|
final String senderType;
|
||||||
|
final String content;
|
||||||
|
final String type;
|
||||||
|
final String status;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
ChatMessage({
|
||||||
|
required this.id,
|
||||||
|
required this.senderType,
|
||||||
|
required this.content,
|
||||||
|
this.type = 'text',
|
||||||
|
this.status = 'sent',
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
ChatMessage copyWith({String? status}) {
|
||||||
|
return ChatMessage(
|
||||||
|
id: id,
|
||||||
|
senderType: senderType,
|
||||||
|
content: content,
|
||||||
|
type: type,
|
||||||
|
status: status ?? this.status,
|
||||||
|
createdAt: createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloc
|
||||||
|
class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||||
|
final ApiClient apiClient;
|
||||||
|
WebSocketChannel? _channel;
|
||||||
|
StreamSubscription? _wsSubscription;
|
||||||
|
Timer? _typingTimer;
|
||||||
|
|
||||||
|
MitraChatBloc({required this.apiClient}) : super(ChatInitial()) {
|
||||||
|
on<ConnectChat>(_onConnect);
|
||||||
|
on<DisconnectChat>(_onDisconnect);
|
||||||
|
on<SendMessage>(_onSendMessage);
|
||||||
|
on<SendTyping>(_onSendTyping);
|
||||||
|
on<_MessageReceived>(_onMessageReceived);
|
||||||
|
on<_ConnectionError>(_onConnectionError);
|
||||||
|
on<MarkMessagesDelivered>(_onMarkDelivered);
|
||||||
|
on<MarkMessagesRead>(_onMarkRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onConnect(ConnectChat event, Emitter<MitraChatState> emit) async {
|
||||||
|
emit(ChatConnecting());
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await apiClient.get('/api/shared/chat/${event.sessionId}/messages');
|
||||||
|
final messagesData = response['data'] as List<dynamic>;
|
||||||
|
final messages = messagesData.map((m) => ChatMessage(
|
||||||
|
id: m['id'] as String,
|
||||||
|
senderType: m['sender_type'] as String,
|
||||||
|
content: m['content'] as String,
|
||||||
|
type: m['type'] as String? ?? 'text',
|
||||||
|
status: m['status'] as String? ?? 'sent',
|
||||||
|
createdAt: DateTime.parse(m['created_at'] as String),
|
||||||
|
)).toList();
|
||||||
|
|
||||||
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
|
final token = await user?.getIdToken();
|
||||||
|
final wsUrl = ApiClient.baseUrl
|
||||||
|
.replaceFirst('https://', 'wss://')
|
||||||
|
.replaceFirst('http://', 'ws://');
|
||||||
|
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
|
||||||
|
|
||||||
|
_wsSubscription = _channel!.stream.listen(
|
||||||
|
(raw) {
|
||||||
|
final data = jsonDecode(raw as String) as Map<String, dynamic>;
|
||||||
|
add(_MessageReceived(data));
|
||||||
|
},
|
||||||
|
onError: (_) => add(_ConnectionError()),
|
||||||
|
onDone: () => add(_ConnectionError()),
|
||||||
|
);
|
||||||
|
|
||||||
|
_channel!.sink.add(jsonEncode({
|
||||||
|
'type': 'auth',
|
||||||
|
'token': token,
|
||||||
|
'session_id': event.sessionId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
emit(ChatConnected(messages: messages));
|
||||||
|
} catch (e) {
|
||||||
|
emit(ChatError('Gagal terhubung ke chat.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDisconnect(DisconnectChat event, Emitter<MitraChatState> emit) {
|
||||||
|
_cleanup();
|
||||||
|
emit(ChatInitial());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSendMessage(SendMessage event, Emitter<MitraChatState> emit) {
|
||||||
|
if (state is! ChatConnected || _channel == null) return;
|
||||||
|
final current = state as ChatConnected;
|
||||||
|
|
||||||
|
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final msg = ChatMessage(
|
||||||
|
id: tempId,
|
||||||
|
senderType: 'mitra',
|
||||||
|
content: event.content,
|
||||||
|
status: 'sending',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||||
|
|
||||||
|
_channel!.sink.add(jsonEncode({
|
||||||
|
'type': 'message',
|
||||||
|
'content': event.content,
|
||||||
|
'_temp_id': tempId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSendTyping(SendTyping event, Emitter<MitraChatState> emit) {
|
||||||
|
if (_channel == null) return;
|
||||||
|
_channel!.sink.add(jsonEncode({'type': 'typing'}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<MitraChatState> emit) {
|
||||||
|
if (_channel == null) return;
|
||||||
|
_channel!.sink.add(jsonEncode({'type': 'delivered', 'message_ids': event.messageIds}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMarkRead(MarkMessagesRead event, Emitter<MitraChatState> emit) {
|
||||||
|
if (_channel == null) return;
|
||||||
|
_channel!.sink.add(jsonEncode({'type': 'read', 'message_ids': event.messageIds}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMessageReceived(_MessageReceived event, Emitter<MitraChatState> emit) {
|
||||||
|
if (state is! ChatConnected) return;
|
||||||
|
final current = state as ChatConnected;
|
||||||
|
final data = event.data;
|
||||||
|
final type = data['type'] as String?;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'auth_ok':
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'message':
|
||||||
|
final msg = ChatMessage(
|
||||||
|
id: data['message_id'] as String,
|
||||||
|
senderType: data['sender_type'] as String,
|
||||||
|
content: data['content'] as String,
|
||||||
|
type: data['message_type'] as String? ?? 'text',
|
||||||
|
status: 'sent',
|
||||||
|
createdAt: DateTime.parse(data['created_at'] as String),
|
||||||
|
);
|
||||||
|
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||||
|
add(MarkMessagesDelivered([msg.id]));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'message_ack':
|
||||||
|
final messageId = data['message_id'] as String;
|
||||||
|
final status = data['status'] as String;
|
||||||
|
final updatedMessages = current.messages.map((m) {
|
||||||
|
if (m.status == 'sending') return m.copyWith(status: status);
|
||||||
|
return m;
|
||||||
|
}).toList();
|
||||||
|
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == 'mitra');
|
||||||
|
if (idx >= 0) {
|
||||||
|
final old = updatedMessages[idx];
|
||||||
|
updatedMessages[idx] = ChatMessage(
|
||||||
|
id: messageId,
|
||||||
|
senderType: old.senderType,
|
||||||
|
content: old.content,
|
||||||
|
type: old.type,
|
||||||
|
status: status,
|
||||||
|
createdAt: old.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
emit(current.copyWith(messages: updatedMessages));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'message_status':
|
||||||
|
final messageIds = (data['message_ids'] as List<dynamic>).cast<String>();
|
||||||
|
final status = data['status'] as String;
|
||||||
|
final updatedMessages = current.messages.map((m) {
|
||||||
|
if (messageIds.contains(m.id)) return m.copyWith(status: status);
|
||||||
|
return m;
|
||||||
|
}).toList();
|
||||||
|
emit(current.copyWith(messages: updatedMessages));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'typing':
|
||||||
|
emit(current.copyWith(isOtherTyping: true));
|
||||||
|
_typingTimer?.cancel();
|
||||||
|
_typingTimer = Timer(const Duration(seconds: 3), () {
|
||||||
|
if (state is ChatConnected) {
|
||||||
|
emit((state as ChatConnected).copyWith(isOtherTyping: false));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'session_timer':
|
||||||
|
emit(current.copyWith(remainingSeconds: data['remaining_seconds'] as int?));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'session_expired':
|
||||||
|
emit(current.copyWith(sessionExpired: true));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'extension_request':
|
||||||
|
emit(current.copyWith(extensionRequest: data));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'session_resumed':
|
||||||
|
emit(current.copyWith(sessionExpired: false, extensionRequest: null));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'session_closing':
|
||||||
|
emit(current.copyWith(sessionClosing: true));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'session_completed':
|
||||||
|
_cleanup();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onConnectionError(_ConnectionError event, Emitter<MitraChatState> emit) {}
|
||||||
|
|
||||||
|
void _cleanup() {
|
||||||
|
_wsSubscription?.cancel();
|
||||||
|
_wsSubscription = null;
|
||||||
|
_channel?.sink.close();
|
||||||
|
_channel = null;
|
||||||
|
_typingTimer?.cancel();
|
||||||
|
_typingTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_cleanup();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
71
mitra_app/lib/features/chat/screens/chat_history_screen.dart
Normal file
71
mitra_app/lib/features/chat/screens/chat_history_screen.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/api/api_client.dart';
|
||||||
|
|
||||||
|
class MitraChatHistoryScreen extends StatefulWidget {
|
||||||
|
const MitraChatHistoryScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MitraChatHistoryScreen> createState() => _MitraChatHistoryScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MitraChatHistoryScreenState extends State<MitraChatHistoryScreen> {
|
||||||
|
List<Map<String, dynamic>> _sessions = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadHistory() async {
|
||||||
|
try {
|
||||||
|
final api = context.read<ApiClient>();
|
||||||
|
final response = await api.get('/api/mitra/chat-requests/history');
|
||||||
|
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
|
||||||
|
setState(() {
|
||||||
|
_sessions = items;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Riwayat Chat')),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _sessions.isEmpty
|
||||||
|
? const Center(child: Text('Belum ada riwayat chat'))
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: _sessions.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final s = _sessions[index];
|
||||||
|
final customerName = s['customer_display_name'] as String? ?? 'Customer';
|
||||||
|
final endedAt = s['ended_at'] != null
|
||||||
|
? DateTime.parse(s['ended_at'] as String).toLocal()
|
||||||
|
: null;
|
||||||
|
final duration = s['duration_minutes'] as int?;
|
||||||
|
final closureMsg = s['mitra_closure_message'] as String?;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: const CircleAvatar(child: Icon(Icons.person)),
|
||||||
|
title: Text(customerName),
|
||||||
|
subtitle: Text([
|
||||||
|
if (endedAt != null) '${endedAt.day}/${endedAt.month}/${endedAt.year}',
|
||||||
|
if (duration != null) '$duration menit',
|
||||||
|
if (closureMsg != null) '"$closureMsg"',
|
||||||
|
].join(' - ')),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => context.push('/chat/history/${s['id']}'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../core/api/api_client.dart';
|
||||||
|
|
||||||
|
class MitraChatTranscriptScreen extends StatefulWidget {
|
||||||
|
final String sessionId;
|
||||||
|
|
||||||
|
const MitraChatTranscriptScreen({super.key, required this.sessionId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MitraChatTranscriptScreen> createState() => _MitraChatTranscriptScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MitraChatTranscriptScreenState extends State<MitraChatTranscriptScreen> {
|
||||||
|
List<Map<String, dynamic>> _messages = [];
|
||||||
|
List<Map<String, dynamic>> _closures = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadTranscript();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadTranscript() async {
|
||||||
|
try {
|
||||||
|
final api = context.read<ApiClient>();
|
||||||
|
final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript');
|
||||||
|
final data = response['data'] as Map<String, dynamic>;
|
||||||
|
setState(() {
|
||||||
|
_messages = (data['messages'] as List<dynamic>).cast<Map<String, dynamic>>();
|
||||||
|
_closures = (data['closures'] as List<dynamic>).cast<Map<String, dynamic>>();
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Transkrip Chat')),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
..._messages.map((m) {
|
||||||
|
final isMe = m['sender_type'] == 'mitra';
|
||||||
|
final time = DateTime.parse(m['created_at'] as String).toLocal();
|
||||||
|
return Align(
|
||||||
|
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isMe ? Colors.green.shade100 : Colors.grey.shade200,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(m['content'] as String, style: const TextStyle(fontSize: 15)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}',
|
||||||
|
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
if (_closures.isNotEmpty) ...[
|
||||||
|
const Divider(height: 32),
|
||||||
|
const Text('Pesan Penutup', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._closures.map((c) => Card(
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(c['user_type'] == 'mitra' ? 'Kamu' : 'Customer'),
|
||||||
|
subtitle: Text(c['message'] as String),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
337
mitra_app/lib/features/chat/screens/mitra_chat_screen.dart
Normal file
337
mitra_app/lib/features/chat/screens/mitra_chat_screen.dart
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/chat/mitra_chat_bloc.dart';
|
||||||
|
import '../../../core/chat/extension_bloc.dart';
|
||||||
|
|
||||||
|
class MitraChatScreen extends StatefulWidget {
|
||||||
|
final String sessionId;
|
||||||
|
final String customerName;
|
||||||
|
|
||||||
|
const MitraChatScreen({super.key, required this.sessionId, required this.customerName});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MitraChatScreen> createState() => _MitraChatScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MitraChatScreenState extends State<MitraChatScreen> {
|
||||||
|
final _messageController = TextEditingController();
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
Timer? _typingThrottle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_messageController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
_typingThrottle?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToBottom() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTextChanged(String text) {
|
||||||
|
if (_typingThrottle?.isActive ?? false) return;
|
||||||
|
context.read<MitraChatBloc>().add(SendTyping());
|
||||||
|
_typingThrottle = Timer(const Duration(seconds: 2), () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendMessage() {
|
||||||
|
final text = _messageController.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
context.read<MitraChatBloc>().add(SendMessage(text));
|
||||||
|
_messageController.clear();
|
||||||
|
_scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MultiBlocListener(
|
||||||
|
listeners: [
|
||||||
|
BlocListener<MitraChatBloc, MitraChatState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state is ChatConnected) {
|
||||||
|
_scrollToBottom();
|
||||||
|
final unread = state.messages
|
||||||
|
.where((m) => m.senderType == 'customer' && m.status != 'read')
|
||||||
|
.map((m) => m.id)
|
||||||
|
.toList();
|
||||||
|
if (unread.isNotEmpty) {
|
||||||
|
context.read<MitraChatBloc>().add(MarkMessagesRead(unread));
|
||||||
|
}
|
||||||
|
if (state.sessionClosing) {
|
||||||
|
// Trigger goodbye view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
BlocListener<ExtensionBloc, ExtensionState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state is ExtensionComplete) {
|
||||||
|
context.go('/home');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(widget.customerName),
|
||||||
|
actions: [
|
||||||
|
BlocBuilder<MitraChatBloc, MitraChatState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is ChatConnected && state.remainingSeconds != null) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'${state.remainingSeconds}s',
|
||||||
|
style: TextStyle(
|
||||||
|
color: state.remainingSeconds! < 30 ? Colors.red : null,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: BlocBuilder<MitraChatBloc, MitraChatState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is ChatConnecting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (state is ChatError) {
|
||||||
|
return Center(child: Text(state.message));
|
||||||
|
}
|
||||||
|
if (state is ChatConnected) {
|
||||||
|
return _buildChatBody(context, state);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChatBody(BuildContext context, ChatConnected state) {
|
||||||
|
// Extension request from customer
|
||||||
|
if (state.extensionRequest != null) {
|
||||||
|
return _buildExtensionView(context, state.extensionRequest!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Goodbye view
|
||||||
|
final extState = context.watch<ExtensionBloc>().state;
|
||||||
|
if (state.sessionClosing || extState is ExtensionShowGoodbye || extState is ExtensionSubmitting) {
|
||||||
|
return _buildGoodbyeView(context, extState);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: state.messages.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final msg = state.messages[index];
|
||||||
|
final isMe = msg.senderType == 'mitra';
|
||||||
|
return _buildMessageBubble(msg, isMe);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state.isOtherTyping)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text('Customer sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildInputBar(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageBubble(ChatMessage msg, bool isMe) {
|
||||||
|
return Align(
|
||||||
|
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isMe ? Colors.green.shade100 : Colors.grey.shade200,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(msg.content, style: const TextStyle(fontSize: 15)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${msg.createdAt.hour.toString().padLeft(2, '0')}:${msg.createdAt.minute.toString().padLeft(2, '0')}',
|
||||||
|
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
||||||
|
),
|
||||||
|
if (isMe) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_buildStatusIcon(msg.status),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusIcon(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'sending':
|
||||||
|
return const Icon(Icons.access_time, size: 14, color: Colors.grey);
|
||||||
|
case 'sent':
|
||||||
|
return const Icon(Icons.check, size: 14, color: Colors.grey);
|
||||||
|
case 'delivered':
|
||||||
|
return const Icon(Icons.done_all, size: 14, color: Colors.grey);
|
||||||
|
case 'read':
|
||||||
|
return const Icon(Icons.done_all, size: 14, color: Colors.blue);
|
||||||
|
default:
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInputBar() {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _messageController,
|
||||||
|
onChanged: _onTextChanged,
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
onSubmitted: (_) => _sendMessage(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Ketik pesan...',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.send, color: Colors.green),
|
||||||
|
onPressed: _sendMessage,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildExtensionView(BuildContext context, Map<String, dynamic> request) {
|
||||||
|
final duration = request['duration_minutes'] as int?;
|
||||||
|
final extensionId = request['extension_id'] as String?;
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.timer, size: 64, color: Colors.orange),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
|
||||||
|
onPressed: () => context.read<ExtensionBloc>().add(RespondToExtension(
|
||||||
|
sessionId: widget.sessionId,
|
||||||
|
extensionId: extensionId!,
|
||||||
|
accepted: true,
|
||||||
|
)),
|
||||||
|
child: const Text('Terima', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
onPressed: () => context.read<ExtensionBloc>().add(RespondToExtension(
|
||||||
|
sessionId: widget.sessionId,
|
||||||
|
extensionId: extensionId!,
|
||||||
|
accepted: false,
|
||||||
|
)),
|
||||||
|
child: const Text('Tolak', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGoodbyeView(BuildContext context, ExtensionState extState) {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.waving_hand, size: 64, color: Colors.amber),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Tuliskan pesan terakhirmu untuk Customer', textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Terima kasih sudah curhat...',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: extState is ExtensionSubmitting
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
final text = controller.text.trim();
|
||||||
|
if (text.isNotEmpty) {
|
||||||
|
context.read<ExtensionBloc>().add(
|
||||||
|
SubmitGoodbye(sessionId: widget.sessionId, message: text),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: extState is ExtensionSubmitting
|
||||||
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Text('Kirim & Selesai'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -170,13 +170,25 @@ class _StatusToggle extends StatelessWidget {
|
|||||||
class _ActiveSessionsButton extends StatelessWidget {
|
class _ActiveSessionsButton extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Column(
|
||||||
child: ListTile(
|
children: [
|
||||||
leading: const Icon(Icons.chat_bubble_outline),
|
Card(
|
||||||
title: const Text('Sesi Aktif'),
|
child: ListTile(
|
||||||
trailing: const Icon(Icons.chevron_right),
|
leading: const Icon(Icons.chat_bubble_outline),
|
||||||
onTap: () => Navigator.of(context).pushNamed('/sessions'),
|
title: const Text('Sesi Aktif'),
|
||||||
),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => Navigator.of(context).pushNamed('/sessions'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.history),
|
||||||
|
title: const Text('Riwayat Chat'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => Navigator.of(context).pushNamed('/chat/history'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
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 'package:go_router/go_router.dart';
|
||||||
@@ -6,12 +7,18 @@ 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/status/status_bloc.dart';
|
||||||
import 'core/chat/chat_request_bloc.dart';
|
import 'core/chat/chat_request_bloc.dart';
|
||||||
|
import 'core/chat/mitra_chat_bloc.dart';
|
||||||
|
import 'core/chat/extension_bloc.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
import 'router.dart';
|
import 'router.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||||
|
|
||||||
|
final messaging = FirebaseMessaging.instance;
|
||||||
|
await messaging.requestPermission();
|
||||||
|
|
||||||
runApp(const App());
|
runApp(const App());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +45,18 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
_router = buildRouter(_authBloc);
|
_router = buildRouter(_authBloc);
|
||||||
_statusBloc = StatusBloc(apiClient: _apiClient);
|
_statusBloc = StatusBloc(apiClient: _apiClient);
|
||||||
_chatRequestBloc = ChatRequestBloc(apiClient: _apiClient);
|
_chatRequestBloc = ChatRequestBloc(apiClient: _apiClient);
|
||||||
|
_registerFcmToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _registerFcmToken() {
|
||||||
|
return _authBloc.stream.where((s) => s is AuthAuthenticated).first.then((_) async {
|
||||||
|
try {
|
||||||
|
final token = await FirebaseMessaging.instance.getToken();
|
||||||
|
if (token != null) {
|
||||||
|
await _apiClient.post('/api/shared/device-token', data: {'token': token});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -66,6 +85,8 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
BlocProvider.value(value: _authBloc),
|
BlocProvider.value(value: _authBloc),
|
||||||
BlocProvider.value(value: _statusBloc),
|
BlocProvider.value(value: _statusBloc),
|
||||||
BlocProvider.value(value: _chatRequestBloc),
|
BlocProvider.value(value: _chatRequestBloc),
|
||||||
|
BlocProvider(create: (_) => MitraChatBloc(apiClient: _apiClient)),
|
||||||
|
BlocProvider(create: (_) => ExtensionBloc(apiClient: _apiClient)),
|
||||||
RepositoryProvider.value(value: _apiClient),
|
RepositoryProvider.value(value: _apiClient),
|
||||||
],
|
],
|
||||||
child: BlocListener<AuthBloc, AuthState>(
|
child: BlocListener<AuthBloc, AuthState>(
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ 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';
|
import 'features/chat/screens/active_sessions_screen.dart';
|
||||||
|
import 'features/chat/screens/mitra_chat_screen.dart';
|
||||||
|
import 'features/chat/screens/chat_history_screen.dart';
|
||||||
|
import 'features/chat/screens/chat_transcript_screen.dart';
|
||||||
|
|
||||||
class _BlocRefreshNotifier extends ChangeNotifier {
|
class _BlocRefreshNotifier extends ChangeNotifier {
|
||||||
late final StreamSubscription _subscription;
|
late final StreamSubscription _subscription;
|
||||||
@@ -40,6 +43,17 @@ GoRouter buildRouter(AuthBloc authBloc) {
|
|||||||
GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
|
GoRoute(path: '/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()),
|
GoRoute(path: '/sessions', builder: (_, __) => const ActiveSessionsScreen()),
|
||||||
|
GoRoute(path: '/chat/session/:sessionId', builder: (context, state) {
|
||||||
|
final extra = state.extra as Map<String, dynamic>?;
|
||||||
|
return MitraChatScreen(
|
||||||
|
sessionId: state.pathParameters['sessionId']!,
|
||||||
|
customerName: extra?['customerName'] as String? ?? 'Customer',
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
GoRoute(path: '/chat/history', builder: (_, __) => const MitraChatHistoryScreen()),
|
||||||
|
GoRoute(path: '/chat/history/:sessionId', builder: (context, state) {
|
||||||
|
return MitraChatTranscriptScreen(sessionId: state.pathParameters['sessionId']!);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
crypto:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -137,6 +145,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.17.5"
|
version: "2.17.5"
|
||||||
|
firebase_messaging:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: firebase_messaging
|
||||||
|
sha256: a1662cc95d9750a324ad9df349b873360af6f11414902021f130c68ec02267c4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "14.9.4"
|
||||||
|
firebase_messaging_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_messaging_platform_interface
|
||||||
|
sha256: "87c4a922cb6f811cfb7a889bdbb3622702443c52a0271636cbc90d813ceac147"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.37"
|
||||||
|
firebase_messaging_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_messaging_web
|
||||||
|
sha256: "0d34dca01a7b103ed7f20138bffbb28eb0e61a677bf9e78a028a932e2c7322d5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.8.7"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -373,6 +405,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.1"
|
version: "0.5.1"
|
||||||
|
web_socket_channel:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: web_socket_channel
|
||||||
|
sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.5"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.8.0-0 <4.0.0"
|
dart: ">=3.8.0-0 <4.0.0"
|
||||||
flutter: ">=3.18.0-18.0.pre.54"
|
flutter: ">=3.18.0-18.0.pre.54"
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ dependencies:
|
|||||||
# Firebase
|
# Firebase
|
||||||
firebase_core: ^2.27.1
|
firebase_core: ^2.27.1
|
||||||
firebase_auth: ^4.18.0
|
firebase_auth: ^4.18.0
|
||||||
|
firebase_messaging: ^14.7.15
|
||||||
|
|
||||||
# HTTP
|
# HTTP & WebSocket
|
||||||
dio: ^5.4.3
|
dio: ^5.4.3
|
||||||
|
web_socket_channel: ^2.4.5
|
||||||
|
|
||||||
# State management
|
# State management
|
||||||
flutter_bloc: ^8.1.5
|
flutter_bloc: ^8.1.5
|
||||||
|
|||||||
475
requirement/phase3-plan.md
Normal file
475
requirement/phase3-plan.md
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
# Phase 3 Implementation Plan: Chat Engine
|
||||||
|
|
||||||
|
## Summary of Clarified Requirements
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Chat opening | Time/price selection dialog before pairing |
|
||||||
|
| Price tiers (mock) | 15min/30k, 30min/60k, 45min/100k, 60min/150k, 24jam/250k |
|
||||||
|
| Free trial | One-time per customer; enabled/disabled globally via Control Center |
|
||||||
|
| Free trial duration | Single global config value (minutes) in Control Center |
|
||||||
|
| Payment timing | After pairing starts, but mocked in this phase |
|
||||||
|
| Chat transport | WebSocket (real-time) + FCM (background push notifications) |
|
||||||
|
| Message types | Text-only now; schema supports image/voice/video later |
|
||||||
|
| Emoji | Works natively (unicode text), no special handling needed |
|
||||||
|
| Message status | Sent (server ack), Delivered (client ack), Read (client opened) |
|
||||||
|
| Typing indicator | 3-second timeout, throttled (send at most once per 2-3s) |
|
||||||
|
| Chat history storage | Backend API as source of truth; no local cache |
|
||||||
|
| Session timer | Backend-authoritative (server-side countdown) |
|
||||||
|
| Timer warning | Show remaining time at 1 minute left |
|
||||||
|
| Extension timeout | 1 minute for customer to decide + mitra to confirm (configurable) |
|
||||||
|
| Chat during extension | Paused — no messages until extension confirmed or rejected |
|
||||||
|
| Early end | Mechanism built, disabled by default, configurable per role |
|
||||||
|
| Closing message | Free-text goodbye message from both parties |
|
||||||
|
| Chat history view | Full read-only transcript, kept forever |
|
||||||
|
| Deletion requests | Deferred to later phase |
|
||||||
|
| Sessions per customer | One active session at a time |
|
||||||
|
| WSS termination | Cloud Run handles TLS; backend uses plain `ws://` |
|
||||||
|
| Control center transcripts | Viewable by certain roles only (deferred to later phase) |
|
||||||
|
| Notification permission | Request on first app launch |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Database Changes
|
||||||
|
|
||||||
|
### 1.1 New table: `chat_messages`
|
||||||
|
|
||||||
|
Stores all chat messages.
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `SERIAL PRIMARY KEY` | |
|
||||||
|
| `session_id` | `INT REFERENCES chat_sessions(id)` | |
|
||||||
|
| `sender_type` | `VARCHAR` | `'customer'` or `'mitra'` |
|
||||||
|
| `sender_id` | `INT` | customer or mitra ID |
|
||||||
|
| `type` | `VARCHAR DEFAULT 'text'` | `'text'` now; `'image'`, `'voice'`, `'video'` later |
|
||||||
|
| `content` | `TEXT` | Message text (or file URL for future media) |
|
||||||
|
| `metadata` | `JSONB` | Nullable; for future media (file size, duration, thumbnail) |
|
||||||
|
| `status` | `VARCHAR DEFAULT 'sent'` | `'sent'`, `'delivered'`, `'read'` |
|
||||||
|
| `delivered_at` | `TIMESTAMPTZ` | When recipient's client acknowledged |
|
||||||
|
| `read_at` | `TIMESTAMPTZ` | When recipient opened/read the message |
|
||||||
|
| `created_at` | `TIMESTAMPTZ DEFAULT now()` | |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- `(session_id, created_at)` — fetch messages in chronological order
|
||||||
|
- `(session_id, status)` — query undelivered/unread messages
|
||||||
|
|
||||||
|
### 1.2 New table: `session_closures`
|
||||||
|
|
||||||
|
Stores goodbye messages when a session ends.
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `SERIAL PRIMARY KEY` | |
|
||||||
|
| `session_id` | `INT REFERENCES chat_sessions(id)` | |
|
||||||
|
| `user_type` | `VARCHAR` | `'customer'` or `'mitra'` |
|
||||||
|
| `user_id` | `INT` | customer or mitra ID |
|
||||||
|
| `message` | `TEXT` | Free-text goodbye message |
|
||||||
|
| `created_at` | `TIMESTAMPTZ DEFAULT now()` | |
|
||||||
|
|
||||||
|
### 1.3 New table: `session_extensions`
|
||||||
|
|
||||||
|
Tracks extension requests and their outcomes.
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `SERIAL PRIMARY KEY` | |
|
||||||
|
| `session_id` | `INT REFERENCES chat_sessions(id)` | |
|
||||||
|
| `requested_duration_minutes` | `INT` | Duration customer selected |
|
||||||
|
| `requested_price` | `INT` | Mock price (in IDR) |
|
||||||
|
| `status` | `VARCHAR` | `'pending'`, `'accepted'`, `'rejected'`, `'timeout'` |
|
||||||
|
| `requested_at` | `TIMESTAMPTZ DEFAULT now()` | |
|
||||||
|
| `responded_at` | `TIMESTAMPTZ` | |
|
||||||
|
|
||||||
|
### 1.4 Alter table: `chat_sessions`
|
||||||
|
|
||||||
|
Add columns to support timed sessions:
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `duration_minutes` | `INT` | Selected duration (15/30/45/60/1440) |
|
||||||
|
| `price` | `INT` | Mock price in IDR (0 for free trial) |
|
||||||
|
| `is_free_trial` | `BOOLEAN DEFAULT false` | |
|
||||||
|
| `expires_at` | `TIMESTAMPTZ` | Computed: `paired_at + duration_minutes` |
|
||||||
|
| `extended_minutes` | `INT DEFAULT 0` | Total extended time |
|
||||||
|
|
||||||
|
Add new session statuses:
|
||||||
|
- `extending` — customer requested extension, waiting for mitra confirmation
|
||||||
|
- `closing` — session ended, waiting for goodbye messages
|
||||||
|
|
||||||
|
### 1.5 New table: `customer_transactions`
|
||||||
|
|
||||||
|
Tracks whether a customer has had any transaction (for free trial eligibility).
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `SERIAL PRIMARY KEY` | |
|
||||||
|
| `customer_id` | `INT REFERENCES customers(id)` | |
|
||||||
|
| `session_id` | `INT REFERENCES chat_sessions(id)` | |
|
||||||
|
| `type` | `VARCHAR` | `'free_trial'`, `'paid'`, `'extension'` |
|
||||||
|
| `amount` | `INT` | 0 for free trial |
|
||||||
|
| `created_at` | `TIMESTAMPTZ DEFAULT now()` | |
|
||||||
|
|
||||||
|
### 1.6 Extend `app_config`
|
||||||
|
|
||||||
|
New config keys:
|
||||||
|
|
||||||
|
| Key | Value (JSONB) | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `free_trial_enabled` | `{ "value": true }` | Enable/disable free trial globally |
|
||||||
|
| `free_trial_duration_minutes` | `{ "value": 5 }` | Free trial session duration |
|
||||||
|
| `extension_timeout_seconds` | `{ "value": 60 }` | Time limit for extension negotiation |
|
||||||
|
| `early_end_mitra_enabled` | `{ "value": false }` | Allow mitra to end session early |
|
||||||
|
| `early_end_customer_enabled` | `{ "value": false }` | Allow customer to end session early |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Backend Changes
|
||||||
|
|
||||||
|
### 2.1 WebSocket Setup
|
||||||
|
|
||||||
|
- Add `@fastify/websocket` plugin (`src/plugins/websocket.js`)
|
||||||
|
- Single WebSocket endpoint: `GET /api/shared/ws`
|
||||||
|
- Connection authenticated via Firebase token (sent as query param or first message)
|
||||||
|
- After auth, server identifies user as customer or mitra and joins them to their session channel
|
||||||
|
- Valkey pub/sub remains the backend message bus; WebSocket is the client-facing transport
|
||||||
|
- Architecture: `Client ↔ WebSocket ↔ Backend ↔ Valkey pub/sub ↔ Backend ↔ WebSocket ↔ Other client`
|
||||||
|
|
||||||
|
**WebSocket message types (JSON):**
|
||||||
|
|
||||||
|
| Type | Direction | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `auth` | Client → Server | Authenticate with Firebase token |
|
||||||
|
| `auth_ok` | Server → Client | Authentication successful |
|
||||||
|
| `message` | Client → Server | Send a chat message |
|
||||||
|
| `message` | Server → Client | Receive a chat message |
|
||||||
|
| `message_status` | Server → Client | Delivery/read status update |
|
||||||
|
| `message_ack` | Server → Client | Server acknowledges sent message (sent status) |
|
||||||
|
| `typing` | Client → Server | User is typing |
|
||||||
|
| `typing` | Server → Client | Other user is typing |
|
||||||
|
| `session_timer` | Server → Client | Timer warning (1 min left) |
|
||||||
|
| `session_expired` | Server → Client | Session time is up |
|
||||||
|
| `extension_request` | Server → Client | Extension request notification (to mitra) |
|
||||||
|
| `extension_response` | Server → Client | Extension accepted/rejected (to customer) |
|
||||||
|
| `session_paused` | Server → Client | Chat paused during extension negotiation |
|
||||||
|
| `session_resumed` | Server → Client | Chat resumed after extension accepted |
|
||||||
|
| `session_closing` | Server → Client | Session ending, prompt for goodbye message |
|
||||||
|
| `early_end` | Client → Server | Request to end session early |
|
||||||
|
| `delivered` | Client → Server | Client acknowledges message delivery |
|
||||||
|
| `read` | Client → Server | Client marks messages as read |
|
||||||
|
|
||||||
|
### 2.2 FCM Push Notification Setup
|
||||||
|
|
||||||
|
- Use existing `firebase-admin` plugin for sending push notifications
|
||||||
|
- Store FCM device token per user (new columns on `customers` and `mitras` tables)
|
||||||
|
- Send push notification when recipient's WebSocket is not connected
|
||||||
|
- FCM payload includes: message preview, session ID, sender name
|
||||||
|
- On notification tap: deep-link to specific chat screen
|
||||||
|
|
||||||
|
New columns:
|
||||||
|
|
||||||
|
| Table | Column | Type |
|
||||||
|
|---|---|---|
|
||||||
|
| `customers` | `fcm_token` | `VARCHAR` |
|
||||||
|
| `mitras` | `fcm_token` | `VARCHAR` |
|
||||||
|
|
||||||
|
New endpoint:
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/api/shared/device-token` | Register/update FCM device token |
|
||||||
|
|
||||||
|
### 2.3 New Public Routes — Chat Opening
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/client/chat/pricing` | Get mock price tiers + free trial eligibility |
|
||||||
|
| `POST` | `/api/client/chat/request` | Start pairing with selected duration/price (updated from Phase 2) |
|
||||||
|
|
||||||
|
### 2.4 New Public Routes — Chat Messages
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/shared/chat/:sessionId/messages` | Fetch message history (paginated, for reconnect) |
|
||||||
|
| `GET` | `/api/shared/chat/:sessionId/info` | Get session info (timer, status, participants) |
|
||||||
|
|
||||||
|
### 2.5 New Public Routes — Session Closure
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/api/client/sessions/:sessionId/extend` | Customer requests extension with selected duration |
|
||||||
|
| `POST` | `/api/mitra/sessions/:sessionId/extend-response` | Mitra accepts/rejects extension |
|
||||||
|
| `POST` | `/api/shared/sessions/:sessionId/close-message` | Submit goodbye message |
|
||||||
|
|
||||||
|
### 2.6 New Public Routes — Chat History
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/client/chat/history` | List past sessions (with mitra name, closure messages) |
|
||||||
|
| `GET` | `/api/mitra/chat/history` | List past sessions (with customer name, closure messages) |
|
||||||
|
| `GET` | `/api/shared/chat/:sessionId/transcript` | Full read-only chat transcript |
|
||||||
|
|
||||||
|
### 2.7 New Internal Routes — Control Center
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/internal/config/free-trial` | Get free trial config |
|
||||||
|
| `PATCH` | `/internal/config/free-trial` | Update free trial enabled + duration |
|
||||||
|
| `GET` | `/internal/config/extension-timeout` | Get extension timeout config |
|
||||||
|
| `PATCH` | `/internal/config/extension-timeout` | Update extension timeout |
|
||||||
|
| `GET` | `/internal/config/early-end` | Get early end config |
|
||||||
|
| `PATCH` | `/internal/config/early-end` | Update early end per role |
|
||||||
|
|
||||||
|
### 2.8 New Services
|
||||||
|
|
||||||
|
| Service | Responsibilities |
|
||||||
|
|---|---|
|
||||||
|
| `chat.service.js` | Send message, update delivery/read status, fetch message history, typing event relay |
|
||||||
|
| `session-timer.service.js` | Backend-authoritative countdown, expiry check, 1-min warning trigger, extension timeout |
|
||||||
|
| `extension.service.js` | Extension request/response flow, session pause/resume, timeout handling |
|
||||||
|
| `closure.service.js` | Goodbye message submission, session completion, transaction recording |
|
||||||
|
| `notification.service.js` | FCM push notification sending, device token management, online/offline detection |
|
||||||
|
| `pricing.service.js` | Mock price tiers, free trial eligibility check |
|
||||||
|
|
||||||
|
### 2.9 Chat Flow (Backend Detail)
|
||||||
|
|
||||||
|
```
|
||||||
|
Chat Opening:
|
||||||
|
1. Customer opens "Mulai Curhat"
|
||||||
|
2. Backend returns pricing tiers + free trial eligibility
|
||||||
|
- Free trial shown if: free_trial_enabled AND customer has 0 records in customer_transactions
|
||||||
|
3. Customer selects duration/price (or free trial)
|
||||||
|
4. POST /api/client/chat/request with { duration_minutes, price, is_free_trial }
|
||||||
|
5. Backend creates chat_session with duration_minutes, price, is_free_trial
|
||||||
|
6. Existing Phase 2 pairing flow proceeds (blast, accept, etc.)
|
||||||
|
7. On successful pairing:
|
||||||
|
- Set expires_at = now() + duration_minutes
|
||||||
|
- Create customer_transactions record
|
||||||
|
- Start server-side timer
|
||||||
|
|
||||||
|
Chat Messaging:
|
||||||
|
1. Both parties connect via WebSocket after pairing
|
||||||
|
2. Client sends { type: 'message', content: '...' }
|
||||||
|
3. Backend saves to chat_messages (status: 'sent'), publishes via Valkey
|
||||||
|
4. Backend sends message_ack to sender (sent ✓)
|
||||||
|
5. Recipient's WebSocket receives message
|
||||||
|
6. Recipient sends { type: 'delivered' } → backend updates status, notifies sender (delivered ✓✓)
|
||||||
|
7. Recipient views message → sends { type: 'read' } → backend updates status (read ✓✓ blue)
|
||||||
|
8. If recipient offline → backend sends FCM push notification instead
|
||||||
|
|
||||||
|
Session Expiry:
|
||||||
|
1. Backend timer fires 1 min before expires_at
|
||||||
|
2. Send session_timer to both clients via WebSocket
|
||||||
|
3. Both apps show countdown timer
|
||||||
|
4. At expires_at, backend sends session_expired
|
||||||
|
5. Customer gets extend/close dialog
|
||||||
|
6. If extend: POST /api/client/sessions/:id/extend
|
||||||
|
- Session status → extending, chat paused
|
||||||
|
- Mitra gets extension_request via WebSocket
|
||||||
|
- Mitra accepts/rejects within timeout (default 60s)
|
||||||
|
- If accept: extend expires_at, resume chat
|
||||||
|
- If reject or timeout: proceed to closure
|
||||||
|
7. If close (or after rejected extension):
|
||||||
|
- Session status → closing
|
||||||
|
- Both parties submit goodbye message
|
||||||
|
- Session status → completed
|
||||||
|
|
||||||
|
Early End (when enabled):
|
||||||
|
1. User sends { type: 'early_end' } via WebSocket
|
||||||
|
2. Backend checks if early end is enabled for that role
|
||||||
|
3. If enabled: skip to closure flow (step 7 above)
|
||||||
|
4. If disabled: reject with error
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.10 Typing Indicator (Backend Detail)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Client sends { type: 'typing' } via WebSocket
|
||||||
|
2. Backend relays to other party via Valkey pub/sub → WebSocket
|
||||||
|
3. Receiving client shows typing indicator
|
||||||
|
4. Receiving client auto-hides after 3 seconds of no new typing event
|
||||||
|
5. Sending client throttles: at most one typing event per 2 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Client App Changes
|
||||||
|
|
||||||
|
### 3.1 New BLoC: `ChatBloc`
|
||||||
|
|
||||||
|
Manages active chat messaging.
|
||||||
|
|
||||||
|
**Events:** `ConnectWebSocket`, `DisconnectWebSocket`, `SendMessage`, `MessageReceived`, `MessageStatusUpdate`, `TypingStarted`, `TypingStopped`, `SessionTimerWarning`, `SessionExpired`
|
||||||
|
**States:** `ChatInitial`, `ChatConnecting`, `ChatConnected(messages)`, `ChatTimerWarning(remaining)`, `ChatSessionExpired`, `ChatError`
|
||||||
|
|
||||||
|
- On `ConnectWebSocket` → authenticate, load message history from API, listen for incoming messages
|
||||||
|
- On `SendMessage` → send via WebSocket, add to local message list with "sending" state
|
||||||
|
- On `MessageReceived` → add to list, send delivery acknowledgment
|
||||||
|
- On `MessageStatusUpdate` → update message status (sent → delivered → read)
|
||||||
|
|
||||||
|
### 3.2 New BLoC: `ChatOpeningBloc`
|
||||||
|
|
||||||
|
Manages pricing selection and free trial.
|
||||||
|
|
||||||
|
**Events:** `LoadPricing`, `SelectTier`, `SelectFreeTrial`, `ConfirmSelection`
|
||||||
|
**States:** `PricingLoading`, `PricingLoaded(tiers, freeTrialEligible)`, `TierSelected(tier)`, `PricingError`
|
||||||
|
|
||||||
|
- On `LoadPricing` → call `/api/client/chat/pricing`
|
||||||
|
- On `ConfirmSelection` → trigger existing `PairingBloc.RequestPairing` with duration/price
|
||||||
|
|
||||||
|
### 3.3 New BLoC: `SessionClosureBloc`
|
||||||
|
|
||||||
|
Manages extension and goodbye flow.
|
||||||
|
|
||||||
|
**Events:** `SessionExpired`, `RequestExtension`, `ExtensionResult`, `SubmitGoodbye`
|
||||||
|
**States:** `ClosureInitial`, `ShowExtendDialog`, `ExtendingWaitingMitra`, `ExtensionAccepted`, `ExtensionRejected`, `ShowGoodbyeInput`, `ClosureComplete`
|
||||||
|
|
||||||
|
### 3.4 Screen Changes
|
||||||
|
|
||||||
|
| Screen | Changes |
|
||||||
|
|---|---|
|
||||||
|
| Home screen | "Mulai Curhat" opens pricing dialog instead of directly pairing |
|
||||||
|
| New: Pricing dialog | Bottom sheet with 5 price tiers + free trial option (if eligible) |
|
||||||
|
| New: Chat screen | Full chat UI: message list, text input, send button, typing indicator |
|
||||||
|
| Chat screen | Message bubbles with status icons (✓ sent, ✓✓ delivered, ✓✓ blue read) |
|
||||||
|
| Chat screen | Countdown timer overlay at 1 minute remaining |
|
||||||
|
| New: Extension dialog | "Extend session?" with price tier selection |
|
||||||
|
| New: Waiting mitra dialog | "Menunggu konfirmasi Bestie..." with timeout |
|
||||||
|
| New: Goodbye screen | Free-text input for closing message |
|
||||||
|
| New: Chat history list | List of past sessions (bestie name, date, goodbye message) |
|
||||||
|
| New: Chat transcript screen | Read-only scrollable chat history |
|
||||||
|
|
||||||
|
### 3.5 Navigation Updates
|
||||||
|
|
||||||
|
New routes in GoRouter:
|
||||||
|
|
||||||
|
- `/chat/pricing` — pricing selection dialog/screen
|
||||||
|
- `/chat/session/:sessionId` — active chat screen (updated from Phase 2)
|
||||||
|
- `/chat/history` — chat history list
|
||||||
|
- `/chat/history/:sessionId` — read-only transcript
|
||||||
|
|
||||||
|
### 3.6 FCM Setup
|
||||||
|
|
||||||
|
- Request notification permission on first app launch
|
||||||
|
- Register device token via `POST /api/shared/device-token`
|
||||||
|
- Handle incoming notifications: tap → navigate to specific chat screen
|
||||||
|
- Update token on app launch (tokens can rotate)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Mitra App Changes
|
||||||
|
|
||||||
|
### 4.1 New BLoC: `ChatBloc`
|
||||||
|
|
||||||
|
Same as client app — manages active chat messaging. Shared message type structure.
|
||||||
|
|
||||||
|
**Events/States:** Same as client app ChatBloc.
|
||||||
|
|
||||||
|
### 4.2 New BLoC: `ExtensionBloc`
|
||||||
|
|
||||||
|
Handles incoming extension requests from customers.
|
||||||
|
|
||||||
|
**Events:** `ExtensionReceived`, `AcceptExtension`, `RejectExtension`, `ExtensionTimeout`
|
||||||
|
**States:** `ExtensionIdle`, `ExtensionPending(duration, price)`, `ExtensionAccepted`, `ExtensionRejected`
|
||||||
|
|
||||||
|
### 4.3 Screen Changes
|
||||||
|
|
||||||
|
| Screen | Changes |
|
||||||
|
|---|---|
|
||||||
|
| Home screen | Active sessions list → tap to open chat |
|
||||||
|
| New: Chat screen | Full chat UI (same as client app) |
|
||||||
|
| Chat screen | Extension request overlay when customer requests extension |
|
||||||
|
| New: Extension dialog | "Customer ingin perpanjang X menit" with Accept/Reject buttons |
|
||||||
|
| New: Goodbye screen | Free-text input for closing message |
|
||||||
|
| New: Chat history list | List of past sessions (customer name, date, goodbye message) |
|
||||||
|
| New: Chat transcript screen | Read-only scrollable chat history |
|
||||||
|
|
||||||
|
### 4.4 Navigation Updates
|
||||||
|
|
||||||
|
New routes in GoRouter:
|
||||||
|
|
||||||
|
- `/chat/session/:sessionId` — active chat screen
|
||||||
|
- `/chat/history` — chat history list
|
||||||
|
- `/chat/history/:sessionId` — read-only transcript
|
||||||
|
|
||||||
|
### 4.5 FCM Setup
|
||||||
|
|
||||||
|
- Same as client app: request permission on launch, register token
|
||||||
|
- Push notification on incoming message when app is backgrounded
|
||||||
|
- Tap notification → navigate directly to specific customer's chat screen (not just chat list)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Control Center Changes
|
||||||
|
|
||||||
|
### 5.1 Updated Pages
|
||||||
|
|
||||||
|
| Page | Changes |
|
||||||
|
|---|---|
|
||||||
|
| Settings page | Add: free trial toggle + duration, extension timeout, early end toggles (mitra/customer) |
|
||||||
|
| Session detail page | Add: view chat transcript link (role-restricted, deferred) |
|
||||||
|
|
||||||
|
### 5.2 No New Pages This Phase
|
||||||
|
|
||||||
|
Chat transcript viewing for admins is deferred. Config controls are added to the existing Settings page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Implementation Order
|
||||||
|
|
||||||
|
| Step | What | Apps affected |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Database migration (new tables, altered columns, new config keys) | Backend |
|
||||||
|
| 2 | WebSocket plugin setup (`@fastify/websocket`) | Backend |
|
||||||
|
| 3 | Pricing service + free trial eligibility + chat opening API | Backend |
|
||||||
|
| 4 | Chat message service + WebSocket message handling | Backend |
|
||||||
|
| 5 | Message delivery/read status tracking | Backend |
|
||||||
|
| 6 | Session timer service (backend-authoritative countdown) | Backend |
|
||||||
|
| 7 | Extension service (request/response/timeout) | Backend |
|
||||||
|
| 8 | Closure service (goodbye messages, session completion) | Backend |
|
||||||
|
| 9 | FCM notification service + device token endpoint | Backend |
|
||||||
|
| 10 | Client app: FCM setup + pricing dialog + ChatOpeningBloc | Client app |
|
||||||
|
| 11 | Client app: ChatBloc + chat screen + message status UI | Client app |
|
||||||
|
| 12 | Client app: timer warning + extension dialog + SessionClosureBloc | Client app |
|
||||||
|
| 13 | Client app: goodbye screen + chat history screens | Client app |
|
||||||
|
| 14 | Mitra app: FCM setup + ChatBloc + chat screen | Mitra app |
|
||||||
|
| 15 | Mitra app: ExtensionBloc + extension dialog | Mitra app |
|
||||||
|
| 16 | Mitra app: goodbye screen + chat history screens | Mitra app |
|
||||||
|
| 17 | Control center: settings page updates (free trial, extension, early end) | Control center |
|
||||||
|
| 18 | Typing indicator (WebSocket relay, throttle, 3s timeout) | Backend + both apps |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. New Dependencies
|
||||||
|
|
||||||
|
| App | Package | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Backend | `@fastify/websocket` | WebSocket support (built on `ws`) |
|
||||||
|
| Backend | `firebase-admin` (existing) | FCM push notifications |
|
||||||
|
| Client app | `web_socket_channel` | WebSocket client |
|
||||||
|
| Client app | `firebase_messaging` | FCM push notifications |
|
||||||
|
| Mitra app | `web_socket_channel` | WebSocket client |
|
||||||
|
| Mitra app | `firebase_messaging` | FCM push notifications |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Notes for Future Phases
|
||||||
|
|
||||||
|
**Media messages (image, voice clip, video clip):**
|
||||||
|
- Add file upload endpoint (Cloud Storage)
|
||||||
|
- Add new `type` values to `chat_messages`
|
||||||
|
- Add media bubble widgets in Flutter
|
||||||
|
- No architectural changes — same WebSocket transport, same delivery status, same history
|
||||||
|
|
||||||
|
**Chat transcript for Control Center:**
|
||||||
|
- Add role-based access check
|
||||||
|
- Reuse existing `/api/shared/chat/:sessionId/transcript` endpoint on internal routes
|
||||||
|
|
||||||
|
**Deletion requests:**
|
||||||
|
- Add customer request flow + admin approval
|
||||||
|
- Soft-delete or anonymize messages in `chat_messages`
|
||||||
|
|
||||||
|
**Payment integration (Xendit):**
|
||||||
|
- Replace mock pricing with real payment flow
|
||||||
|
- Integrate at the `pending_payment` status transition point
|
||||||
|
- Extension payments follow same flow
|
||||||
Reference in New Issue
Block a user