Phase 3 testing fixes: Fastify 5, SSE→WebSocket+FCM, enums, security, session lifecycle
- Upgrade Fastify 4→5 with all plugins (@fastify/websocket 11, cors 11, sensible 6) - Migrate all SSE endpoints to WebSocket + FCM push (mitra chat requests, customer pairing status) - Add flutter_local_notifications for foreground push notifications with sound - Add splash screen to both apps (hide auth loading flash) - Introduce constants/enums across entire codebase (no raw string literals) - Move price tiers from hardcoded array to app_config DB (data-driven, includes 1-min test tier) - Add session ownership validation on all shared chat routes - Add ownership checks on endSession, respondToExtension, requestExtension - Fix session timer: auto-complete expired/stale sessions on server restart - Add 5-min grace period for abandoned closing sessions - Fix extension flow: proper session_resumed handling, clearExtensionRequest, closure grace timer cleanup - Fix chat screens: ConnectChat in initState, session status check on connect - Fix customer expired view: 5-min countdown, closure state priority over expired state - Fix mitra extension UI: loading spinner, disable buttons, handle EXTENSION_RESOLVED error - Fix GoRouter navigation consistency (no more Navigator.pushNamed) - Fix goodbye view keyboard overflow (SingleChildScrollView) - Add active session card on customer home screen with refresh on navigate back - Fix PricingBottomSheet extension mode (RequestExtension instead of new pairing) - Send session_resumed to both parties on extension accept Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,83 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm init:*)",
|
"Bash(git clone:*)",
|
||||||
"Bash(cmd.exe /c \"npm --version\")",
|
"Bash(shopt -s dotglob)",
|
||||||
"Bash(flutter --version)"
|
"Bash(cp -rn /tmp/halobestie-clone-temp/* /home/rama/workspaces/workspace-claude/halobestie-clone/)",
|
||||||
|
"Bash(rm -rf /tmp/halobestie-clone-temp)",
|
||||||
|
"Bash(git -C /home/rama/workspaces/workspace-claude/halobestie-clone log --oneline -5)",
|
||||||
|
"Read(//home/rama/workspaces/workspace-claude/backend/src/routes/internal/**)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit -m ':*)",
|
||||||
|
"Bash(git push:*)",
|
||||||
|
"Bash(flutter --version)",
|
||||||
|
"Bash(flutter devices:*)",
|
||||||
|
"Bash(flutter emulators:*)",
|
||||||
|
"Bash(flutter pub:*)",
|
||||||
|
"Bash(flutter run:*)",
|
||||||
|
"Bash(flutter create:*)",
|
||||||
|
"Bash(adb emu:*)",
|
||||||
|
"Bash(firebase --version)",
|
||||||
|
"Bash(flutterfire --version)",
|
||||||
|
"Bash(firebase projects:list)",
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(dart pub:*)",
|
||||||
|
"Bash(pkill -f \"flutter run\")",
|
||||||
|
"Bash(pkill -f \"gradle\")",
|
||||||
|
"Bash(kill 12672 12712 12809 14069 14567)",
|
||||||
|
"Bash(adb -s emulator-5554 emu kill)",
|
||||||
|
"Bash(timeout 5 bash -c 'echo > /dev/tcp/omv.sjamsani.id/5432 && echo \"PostgreSQL: reachable\" || echo \"PostgreSQL: unreachable\"')",
|
||||||
|
"Bash(timeout 5 bash -c 'echo > /dev/tcp/omv.sjamsani.id/6379 && echo \"Valkey: reachable\" || echo \"Valkey: unreachable\"')",
|
||||||
|
"Bash(PGPASSWORD=halobestie_clone psql -h omv.sjamsani.id -U halobestie_clone -d halobestie_clone -c \"SELECT 1 AS connected;\")",
|
||||||
|
"Bash(dpkg -l)",
|
||||||
|
"Bash(npm ls:*)",
|
||||||
|
"Bash(node -e ':*)",
|
||||||
|
"Bash(npm run:*)",
|
||||||
|
"Bash(timeout 5 bash -c 'echo > /dev/tcp/192.168.88.247/3000 && echo \"Backend reachable via static IP\" || echo \"Not reachable\"')",
|
||||||
|
"Bash(pkill -f \"flutter_tools.snapshot run\")",
|
||||||
|
"Bash(curl -s http://192.168.88.247:3000/)",
|
||||||
|
"Bash(lscpu)",
|
||||||
|
"Bash(pkill -f \"flutter_tools.snapshot run.*chrome\")",
|
||||||
|
"Bash(curl -s -X OPTIONS -H \"Origin: http://localhost\" -H \"Access-Control-Request-Method: POST\" -H \"Access-Control-Request-Headers: authorization,content-type\" -I http://192.168.88.247:3000/api/mitra/auth/verify)",
|
||||||
|
"Bash(node -e \"import\\('@fastify/cors'\\).then\\(m => console.log\\('cors loaded ok'\\)\\)\")",
|
||||||
|
"Bash(curl -sv -X OPTIONS -H \"Origin: http://localhost\" -H \"Access-Control-Request-Method: POST\" http://192.168.88.247:3000/api/mitra/auth/verify)",
|
||||||
|
"Bash(pkill -f \"node.*server.js\")",
|
||||||
|
"Bash(curl -sv -X OPTIONS -H \"Origin: http://localhost\" -H \"Access-Control-Request-Method: POST\" -H \"Access-Control-Request-Headers: authorization,content-type\" http://192.168.88.247:3000/api/mitra/auth/verify)",
|
||||||
|
"Bash(pkill -f \"flutter_tools.snapshot run.*emulator\")",
|
||||||
|
"Bash(curl -s http://192.168.88.247:3000/api/shared/config/anonymity)",
|
||||||
|
"Bash(flutter build:*)",
|
||||||
|
"Bash(keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android)",
|
||||||
|
"Read(//home/rama/.android/avd/**)",
|
||||||
|
"Bash(node -e \"const pkg = require\\('./node_modules/@fastify/websocket/package.json'\\); console.log\\('@fastify/websocket:', pkg.version\\); const fp = require\\('./node_modules/fastify/package.json'\\); console.log\\('fastify:', fp.version\\)\")",
|
||||||
|
"Bash(node -e \"const p=require\\('fs'\\).readFileSync\\('/dev/stdin','utf8'\\); const j=JSON.parse\\(p\\); console.log\\(JSON.stringify\\(j.dependencies, null, 2\\)\\)\")",
|
||||||
|
"Bash(node -e \"const p=require\\('fs'\\).readFileSync\\('/dev/stdin','utf8'\\); const j=JSON.parse\\(p\\); console.log\\(JSON.stringify\\(j.scripts, null, 2\\)\\)\")",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch(domain:fastify.dev)",
|
||||||
|
"Bash(grep -E \"\\\\.js$\")",
|
||||||
|
"Bash(node -e \"const f=require\\('fastify/package.json'\\); const w=require\\('@fastify/websocket/package.json'\\); const c=require\\('@fastify/cors/package.json'\\); const s=require\\('@fastify/sensible/package.json'\\); console.log\\('fastify:', f.version, '| websocket:', w.version, '| cors:', c.version, '| sensible:', s.version\\)\")",
|
||||||
|
"Bash(pkill -f \"flutter run.*emulator-5556\")",
|
||||||
|
"Bash(pkill -f \"qemu-system\")",
|
||||||
|
"Bash(kill -9 7413 17891)",
|
||||||
|
"Bash(kill -9 5752 5841)",
|
||||||
|
"Bash(sed -i 's/hw.cpu.ncore=12/hw.cpu.ncore=4/' ~/.android/avd/Medium_Phone.avd/config.ini)",
|
||||||
|
"Bash(sed -i 's/hw.cpu.ncore = 6/hw.cpu.ncore = 4/' ~/.android/avd/Medium_Phone.avd/hardware-qemu.ini)",
|
||||||
|
"Bash(xargs kill:*)",
|
||||||
|
"Bash(pkill -f \"Mitra_Phone\")",
|
||||||
|
"Bash(kill -9 22483 21705)",
|
||||||
|
"Bash(kill -9 46545)",
|
||||||
|
"Bash(node -e \"import admin from 'firebase-admin'; import {initFirebase} from './src/plugins/firebase.js'; initFirebase\\(\\); const t = await admin.auth\\(\\).createCustomToken\\('fUVSXRF3k1S97aqSCPH5S6ZYXZT2'\\); console.log\\(t\\)\")",
|
||||||
|
"Bash(curl -s http://192.168.88.247:3000/api/client/chat/pricing -H \"Authorization: Bearer $\\(node -e \"import admin from 'firebase-admin'; import {initFirebase} from './src/plugins/firebase.js'; initFirebase\\(\\); const t = await admin.auth\\(\\).createCustomToken\\('fUVSXRF3k1S97aqSCPH5S6ZYXZT2'\\); console.log\\(t\\)\" 2>/dev/null\\)\")",
|
||||||
|
"Bash(dart analyze:*)",
|
||||||
|
"Read(//home/rama/workspaces/workspace-claude/**)",
|
||||||
|
"Bash(pkill -f \"flutter run.*52002\")",
|
||||||
|
"Bash(pkill -f \"flutter run.*emulator-5554\")",
|
||||||
|
"Bash(fuser -k 3000/tcp)",
|
||||||
|
"Bash(fuser -k 3001/tcp)",
|
||||||
|
"Bash(fuser 3000/tcp)",
|
||||||
|
"Bash(kill -9 923894)"
|
||||||
|
],
|
||||||
|
"additionalDirectories": [
|
||||||
|
"/home/rama/workspaces/workspace-claude/halobestie-clone/backend/src"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
594
backend/package-lock.json
generated
594
backend/package-lock.json
generated
@@ -8,11 +8,11 @@
|
|||||||
"name": "halo-bestie-backend",
|
"name": "halo-bestie-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^9.0.1",
|
"@fastify/cors": "^11.0.0",
|
||||||
"@fastify/sensible": "^5.6.0",
|
"@fastify/sensible": "^6.0.0",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.0.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^5.0.0",
|
||||||
"firebase-admin": "^12.2.0",
|
"firebase-admin": "^12.2.0",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
@@ -24,14 +24,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fastify/ajv-compiler": {
|
"node_modules/@fastify/ajv-compiler": {
|
||||||
"version": "3.6.0",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
|
||||||
"integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==",
|
"integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.11.0",
|
"ajv": "^8.12.0",
|
||||||
"ajv-formats": "^2.1.1",
|
"ajv-formats": "^3.0.1",
|
||||||
"fast-uri": "^2.0.0"
|
"fast-uri": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fastify/busboy": {
|
"node_modules/@fastify/busboy": {
|
||||||
@@ -41,51 +51,137 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@fastify/cors": {
|
"node_modules/@fastify/cors": {
|
||||||
"version": "9.0.1",
|
"version": "11.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
|
||||||
"integrity": "sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q==",
|
"integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fastify-plugin": "^4.0.0",
|
"fastify-plugin": "^5.0.0",
|
||||||
"mnemonist": "0.39.6"
|
"toad-cache": "^3.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fastify/error": {
|
"node_modules/@fastify/error": {
|
||||||
"version": "3.4.1",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||||
"integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==",
|
"integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@fastify/fast-json-stringify-compiler": {
|
"node_modules/@fastify/fast-json-stringify-compiler": {
|
||||||
"version": "4.3.0",
|
"version": "5.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz",
|
||||||
"integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==",
|
"integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-json-stringify": "^5.7.0"
|
"fast-json-stringify": "^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/forwarded": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@fastify/merge-json-schemas": {
|
"node_modules/@fastify/merge-json-schemas": {
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
|
||||||
"integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==",
|
"integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3"
|
"dequal": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/proxy-addr": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/forwarded": "^3.0.0",
|
||||||
|
"ipaddr.js": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fastify/sensible": {
|
"node_modules/@fastify/sensible": {
|
||||||
"version": "5.6.0",
|
"version": "6.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-6.0.4.tgz",
|
||||||
"integrity": "sha512-Vq6Z2ZQy10GDqON+hvLF52K99s9et5gVVxTul5n3SIAf0Kq5QjPRUKkAMT3zPAiiGvoHtS3APa/3uaxfDgCODQ==",
|
"integrity": "sha512-1vxcCUlPMew6WroK8fq+LVOwbsLtX+lmuRuqpcp6eYqu6vmkLwbKTdBWAZwbeaSgCfW4tzUpTIHLLvTiQQ1BwQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lukeed/ms": "^2.0.1",
|
"@lukeed/ms": "^2.0.2",
|
||||||
"fast-deep-equal": "^3.1.1",
|
"dequal": "^2.0.3",
|
||||||
"fastify-plugin": "^4.0.0",
|
"fastify-plugin": "^5.0.0",
|
||||||
"forwarded": "^0.2.0",
|
"forwarded": "^0.2.0",
|
||||||
"http-errors": "^2.0.0",
|
"http-errors": "^2.0.0",
|
||||||
"type-is": "^1.6.18",
|
"type-is": "^2.0.1",
|
||||||
"vary": "^1.1.2"
|
"vary": "^1.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -110,22 +206,6 @@
|
|||||||
"ws": "^8.16.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",
|
||||||
@@ -594,9 +674,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv-formats": {
|
"node_modules/ajv-formats": {
|
||||||
"version": "2.1.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||||
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.0.0"
|
"ajv": "^8.0.0"
|
||||||
@@ -610,22 +690,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv/node_modules/fast-uri": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/fastify"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/fastify"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
@@ -689,12 +753,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/avvio": {
|
"node_modules/avvio": {
|
||||||
"version": "8.4.0",
|
"version": "9.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz",
|
||||||
"integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==",
|
"integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/error": "^3.3.0",
|
"@fastify/error": "^4.0.0",
|
||||||
"fastq": "^1.17.1"
|
"fastq": "^1.17.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -806,15 +880,28 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/content-type": {
|
||||||
"version": "0.7.2",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -860,6 +947,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dequal": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -1009,12 +1105,6 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-content-type-parse": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/fast-decode-uri-component": {
|
"node_modules/fast-decode-uri-component": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
||||||
@@ -1028,35 +1118,27 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-json-stringify": {
|
"node_modules/fast-json-stringify": {
|
||||||
"version": "5.16.1",
|
"version": "6.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz",
|
||||||
"integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==",
|
"integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==",
|
||||||
"license": "MIT",
|
"funding": [
|
||||||
"dependencies": {
|
{
|
||||||
"@fastify/merge-json-schemas": "^0.1.0",
|
"type": "github",
|
||||||
"ajv": "^8.10.0",
|
"url": "https://github.com/sponsors/fastify"
|
||||||
"ajv-formats": "^3.0.1",
|
},
|
||||||
"fast-deep-equal": "^3.1.3",
|
{
|
||||||
"fast-uri": "^2.1.0",
|
"type": "opencollective",
|
||||||
"json-schema-ref-resolver": "^1.0.1",
|
"url": "https://opencollective.com/fastify"
|
||||||
"rfdc": "^1.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fast-json-stringify/node_modules/ajv-formats": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ajv": "^8.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"ajv": "^8.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"ajv": {
|
|
||||||
"optional": true
|
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/merge-json-schemas": "^0.2.0",
|
||||||
|
"ajv": "^8.12.0",
|
||||||
|
"ajv-formats": "^3.0.1",
|
||||||
|
"fast-uri": "^3.0.0",
|
||||||
|
"json-schema-ref-resolver": "^3.0.0",
|
||||||
|
"rfdc": "^1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-querystring": {
|
"node_modules/fast-querystring": {
|
||||||
@@ -1069,10 +1151,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "2.4.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||||
"integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==",
|
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||||
"license": "MIT"
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-builder": {
|
"node_modules/fast-xml-builder": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
@@ -1112,9 +1204,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fastify": {
|
"node_modules/fastify": {
|
||||||
"version": "4.29.1",
|
"version": "5.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
|
||||||
"integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==",
|
"integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1127,28 +1219,37 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/ajv-compiler": "^3.5.0",
|
"@fastify/ajv-compiler": "^4.0.5",
|
||||||
"@fastify/error": "^3.4.0",
|
"@fastify/error": "^4.0.0",
|
||||||
"@fastify/fast-json-stringify-compiler": "^4.3.0",
|
"@fastify/fast-json-stringify-compiler": "^5.0.0",
|
||||||
|
"@fastify/proxy-addr": "^5.0.0",
|
||||||
"abstract-logging": "^2.0.1",
|
"abstract-logging": "^2.0.1",
|
||||||
"avvio": "^8.3.0",
|
"avvio": "^9.0.0",
|
||||||
"fast-content-type-parse": "^1.1.0",
|
"fast-json-stringify": "^6.0.0",
|
||||||
"fast-json-stringify": "^5.8.0",
|
"find-my-way": "^9.0.0",
|
||||||
"find-my-way": "^8.0.0",
|
"light-my-request": "^6.0.0",
|
||||||
"light-my-request": "^5.11.0",
|
"pino": "^9.14.0 || ^10.1.0",
|
||||||
"pino": "^9.0.0",
|
"process-warning": "^5.0.0",
|
||||||
"process-warning": "^3.0.0",
|
"rfdc": "^1.3.1",
|
||||||
"proxy-addr": "^2.0.7",
|
"secure-json-parse": "^4.0.0",
|
||||||
"rfdc": "^1.3.0",
|
"semver": "^7.6.0",
|
||||||
"secure-json-parse": "^2.7.0",
|
"toad-cache": "^3.7.0"
|
||||||
"semver": "^7.5.4",
|
|
||||||
"toad-cache": "^3.3.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fastify-plugin": {
|
"node_modules/fastify-plugin": {
|
||||||
"version": "4.5.1",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
|
||||||
"integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
@@ -1173,17 +1274,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/find-my-way": {
|
"node_modules/find-my-way": {
|
||||||
"version": "8.2.2",
|
"version": "9.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz",
|
||||||
"integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==",
|
"integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-querystring": "^1.0.0",
|
"fast-querystring": "^1.0.0",
|
||||||
"safe-regex2": "^3.1.0"
|
"safe-regex2": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/firebase-admin": {
|
"node_modules/firebase-admin": {
|
||||||
@@ -1615,12 +1716,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
||||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
"integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-fullwidth-code-point": {
|
"node_modules/is-fullwidth-code-point": {
|
||||||
@@ -1666,12 +1767,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/json-schema-ref-resolver": {
|
"node_modules/json-schema-ref-resolver": {
|
||||||
"version": "1.0.1",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
|
||||||
"integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==",
|
"integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3"
|
"dequal": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
@@ -1740,16 +1851,42 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/light-my-request": {
|
"node_modules/light-my-request": {
|
||||||
"version": "5.14.0",
|
"version": "6.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||||
"integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==",
|
"integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^0.7.0",
|
"cookie": "^1.0.1",
|
||||||
"process-warning": "^3.0.0",
|
"process-warning": "^4.0.0",
|
||||||
"set-cookie-parser": "^2.4.1"
|
"set-cookie-parser": "^2.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/light-my-request/node_modules/process-warning": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/limiter": {
|
"node_modules/limiter": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
||||||
@@ -1862,12 +1999,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/media-typer": {
|
"node_modules/media-typer": {
|
||||||
"version": "0.3.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mime": {
|
"node_modules/mime": {
|
||||||
@@ -1888,6 +2025,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -1897,6 +2035,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "1.52.0"
|
"mime-db": "1.52.0"
|
||||||
},
|
},
|
||||||
@@ -1904,15 +2043,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mnemonist": {
|
|
||||||
"version": "0.39.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
|
|
||||||
"integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"obliterator": "^2.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -1959,12 +2089,6 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/obliterator": {
|
|
||||||
"version": "2.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
|
||||||
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/on-exit-leak-free": {
|
"node_modules/on-exit-leak-free": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||||
@@ -2142,22 +2266,6 @@
|
|||||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pino/node_modules/process-warning": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/fastify"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/fastify"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/postgres": {
|
"node_modules/postgres": {
|
||||||
"version": "3.4.9",
|
"version": "3.4.9",
|
||||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
|
||||||
@@ -2211,9 +2319,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/process-warning": {
|
"node_modules/process-warning": {
|
||||||
"version": "3.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||||
"integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==",
|
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/proto3-json-serializer": {
|
"node_modules/proto3-json-serializer": {
|
||||||
@@ -2254,19 +2372,6 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/proxy-addr": {
|
|
||||||
"version": "2.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
|
||||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"forwarded": "0.2.0",
|
|
||||||
"ipaddr.js": "1.9.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/quick-format-unescaped": {
|
"node_modules/quick-format-unescaped": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
@@ -2337,9 +2442,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ret": {
|
"node_modules/ret": {
|
||||||
"version": "0.4.3",
|
"version": "0.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
|
||||||
"integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==",
|
"integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -2407,12 +2512,25 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/safe-regex2": {
|
"node_modules/safe-regex2": {
|
||||||
"version": "3.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz",
|
||||||
"integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==",
|
"integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ret": "~0.4.0"
|
"ret": "~0.5.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"safe-regex2": "bin/safe-regex2.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/safe-stable-stringify": {
|
"node_modules/safe-stable-stringify": {
|
||||||
@@ -2425,9 +2543,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/secure-json-parse": {
|
"node_modules/secure-json-parse": {
|
||||||
"version": "2.7.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||||
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
|
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
@@ -2659,18 +2787,44 @@
|
|||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"media-typer": "0.3.0",
|
"content-type": "^1.0.5",
|
||||||
"mime-types": "~2.1.24"
|
"media-typer": "^1.1.0",
|
||||||
|
"mime-types": "^3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/type-is/node_modules/mime-db": {
|
||||||
|
"version": "1.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
|
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-is/node_modules/mime-types": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "^1.54.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
|
|||||||
@@ -11,11 +11,11 @@
|
|||||||
"db:seed": "node src/db/seed.js"
|
"db:seed": "node src/db/seed.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^9.0.1",
|
"@fastify/cors": "^11.0.0",
|
||||||
"@fastify/sensible": "^5.6.0",
|
"@fastify/sensible": "^6.0.0",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.0.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^5.0.0",
|
||||||
"firebase-admin": "^12.2.0",
|
"firebase-admin": "^12.2.0",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
|
|||||||
101
backend/src/constants.js
Normal file
101
backend/src/constants.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// User types
|
||||||
|
export const UserType = Object.freeze({
|
||||||
|
CUSTOMER: 'customer',
|
||||||
|
MITRA: 'mitra',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Chat session statuses
|
||||||
|
export const SessionStatus = Object.freeze({
|
||||||
|
SEARCHING: 'searching',
|
||||||
|
PENDING_ACCEPTANCE: 'pending_acceptance',
|
||||||
|
PENDING_PAYMENT: 'pending_payment',
|
||||||
|
ACTIVE: 'active',
|
||||||
|
EXTENDING: 'extending',
|
||||||
|
CLOSING: 'closing',
|
||||||
|
COMPLETED: 'completed',
|
||||||
|
CANCELLED: 'cancelled',
|
||||||
|
EXPIRED: 'expired',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Chat message statuses
|
||||||
|
export const MessageStatus = Object.freeze({
|
||||||
|
SENT: 'sent',
|
||||||
|
DELIVERED: 'delivered',
|
||||||
|
READ: 'read',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Chat message types
|
||||||
|
export const MessageType = Object.freeze({
|
||||||
|
TEXT: 'text',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Chat request notification responses
|
||||||
|
export const NotificationResponse = Object.freeze({
|
||||||
|
ACCEPTED: 'accepted',
|
||||||
|
DECLINED: 'declined',
|
||||||
|
IGNORED: 'ignored',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Session extension statuses
|
||||||
|
export const ExtensionStatus = Object.freeze({
|
||||||
|
PENDING: 'pending',
|
||||||
|
ACCEPTED: 'accepted',
|
||||||
|
REJECTED: 'rejected',
|
||||||
|
TIMEOUT: 'timeout',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Customer transaction types
|
||||||
|
export const TransactionType = Object.freeze({
|
||||||
|
FREE_TRIAL: 'free_trial',
|
||||||
|
PAID: 'paid',
|
||||||
|
EXTENSION: 'extension',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Who ended a session
|
||||||
|
export const EndedBy = Object.freeze({
|
||||||
|
SYSTEM: 'system',
|
||||||
|
CUSTOMER: 'customer',
|
||||||
|
MITRA: 'mitra',
|
||||||
|
})
|
||||||
|
|
||||||
|
// WebSocket message types
|
||||||
|
export const WsMessage = Object.freeze({
|
||||||
|
// Auth
|
||||||
|
AUTH: 'auth',
|
||||||
|
AUTH_OK: 'auth_ok',
|
||||||
|
ERROR: 'error',
|
||||||
|
|
||||||
|
// Chat
|
||||||
|
MESSAGE: 'message',
|
||||||
|
MESSAGE_ACK: 'message_ack',
|
||||||
|
MESSAGE_STATUS: 'message_status',
|
||||||
|
TYPING: 'typing',
|
||||||
|
|
||||||
|
// Pairing
|
||||||
|
CHAT_REQUEST: 'chat_request',
|
||||||
|
CHAT_REQUEST_CLOSED: 'chat_request_closed',
|
||||||
|
PAIRED: 'paired',
|
||||||
|
|
||||||
|
// Session lifecycle
|
||||||
|
SESSION_TIMER: 'session_timer',
|
||||||
|
SESSION_EXPIRED: 'session_expired',
|
||||||
|
SESSION_CLOSING: 'session_closing',
|
||||||
|
SESSION_COMPLETED: 'session_completed',
|
||||||
|
SESSION_ENDED: 'session_ended',
|
||||||
|
SESSION_PAUSED: 'session_paused',
|
||||||
|
SESSION_RESUMED: 'session_resumed',
|
||||||
|
SESSION_ASSIGNED: 'session_assigned',
|
||||||
|
SESSION_REROUTED: 'session_rerouted',
|
||||||
|
REROUTED: 'rerouted',
|
||||||
|
|
||||||
|
// Extension
|
||||||
|
EXTENSION_REQUEST: 'extension_request',
|
||||||
|
EXTENSION_RESPONSE: 'extension_response',
|
||||||
|
|
||||||
|
// Early end
|
||||||
|
EARLY_END: 'early_end',
|
||||||
|
|
||||||
|
// Delivery
|
||||||
|
DELIVERED: 'delivered',
|
||||||
|
READ: 'read',
|
||||||
|
})
|
||||||
@@ -261,6 +261,19 @@ const migrate = async () => {
|
|||||||
ON CONFLICT (key) DO NOTHING
|
ON CONFLICT (key) DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
INSERT INTO app_config (key, value)
|
||||||
|
VALUES ('price_tiers', ${sql.json({ tiers: [
|
||||||
|
{ duration_minutes: 1, price: 5000, label: '1 Menit (Test)' },
|
||||||
|
{ 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' },
|
||||||
|
]})})
|
||||||
|
ON CONFLICT (key) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
console.log('Migration complete.')
|
console.log('Migration complete.')
|
||||||
await sql.end()
|
await sql.end()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { verifyFirebaseToken } from './firebase.js'
|
|||||||
import { getCustomerByFirebaseUid } from '../services/customer.service.js'
|
import { getCustomerByFirebaseUid } from '../services/customer.service.js'
|
||||||
import { getMitraByFirebaseUid } from '../services/mitra.service.js'
|
import { getMitraByFirebaseUid } from '../services/mitra.service.js'
|
||||||
import { subscribe, publish } from './valkey.js'
|
import { subscribe, publish } from './valkey.js'
|
||||||
|
import { UserType, WsMessage } from '../constants.js'
|
||||||
|
|
||||||
// Track active WebSocket connections: sessionId → { customer, mitra }
|
// Track active WebSocket connections: sessionId → { customer, mitra }
|
||||||
const sessionConnections = new Map()
|
const sessionConnections = new Map()
|
||||||
@@ -56,24 +57,24 @@ export const registerWebSocketRoute = (app) => {
|
|||||||
try {
|
try {
|
||||||
msg = JSON.parse(raw.toString())
|
msg = JSON.parse(raw.toString())
|
||||||
} catch {
|
} catch {
|
||||||
send({ type: 'error', message: 'Invalid JSON' })
|
send({ type: WsMessage.ERROR, message: 'Invalid JSON' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle auth message
|
// Handle auth message
|
||||||
if (msg.type === 'auth') {
|
if (msg.type === WsMessage.AUTH) {
|
||||||
try {
|
try {
|
||||||
const decoded = await verifyFirebaseToken(msg.token)
|
const decoded = await verifyFirebaseToken(msg.token)
|
||||||
const customer = await getCustomerByFirebaseUid(decoded.uid)
|
const customer = await getCustomerByFirebaseUid(decoded.uid)
|
||||||
const mitra = customer ? null : await getMitraByFirebaseUid(decoded.uid)
|
const mitra = customer ? null : await getMitraByFirebaseUid(decoded.uid)
|
||||||
|
|
||||||
if (!customer && !mitra) {
|
if (!customer && !mitra) {
|
||||||
send({ type: 'error', message: 'Account not found' })
|
send({ type: WsMessage.ERROR, message: 'Account not found' })
|
||||||
socket.close()
|
socket.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const userType = customer ? 'customer' : 'mitra'
|
const userType = customer ? UserType.CUSTOMER : UserType.MITRA
|
||||||
const userId = customer ? customer.id : mitra.id
|
const userId = customer ? customer.id : mitra.id
|
||||||
const sessionId = msg.session_id
|
const sessionId = msg.session_id
|
||||||
|
|
||||||
@@ -101,9 +102,9 @@ export const registerWebSocketRoute = (app) => {
|
|||||||
valkeyUnsubscribes.push(unsub)
|
valkeyUnsubscribes.push(unsub)
|
||||||
}
|
}
|
||||||
|
|
||||||
send({ type: 'auth_ok', user_type: userType, user_id: userId })
|
send({ type: WsMessage.AUTH_OK, user_type: userType, user_id: userId })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
send({ type: 'error', message: 'Authentication failed' })
|
send({ type: WsMessage.ERROR, message: 'Authentication failed' })
|
||||||
socket.close()
|
socket.close()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -111,7 +112,7 @@ export const registerWebSocketRoute = (app) => {
|
|||||||
|
|
||||||
// All other messages require authentication
|
// All other messages require authentication
|
||||||
if (!authenticatedUser) {
|
if (!authenticatedUser) {
|
||||||
send({ type: 'error', message: 'Not authenticated. Send auth message first.' })
|
send({ type: WsMessage.ERROR, message: 'Not authenticated. Send auth message first.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ export const registerWebSocketRoute = (app) => {
|
|||||||
const conns = sessionConnections.get(authenticatedUser.sessionId)
|
const conns = sessionConnections.get(authenticatedUser.sessionId)
|
||||||
if (conns) {
|
if (conns) {
|
||||||
delete conns[authenticatedUser.type]
|
delete conns[authenticatedUser.type]
|
||||||
if (!conns.customer && !conns.mitra) {
|
if (!conns[UserType.CUSTOMER] && !conns[UserType.MITRA]) {
|
||||||
sessionConnections.delete(authenticatedUser.sessionId)
|
sessionConnections.delete(authenticatedUser.sessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,4 +101,31 @@ export const internalConfigRoutes = async (app) => {
|
|||||||
const config = await setEarlyEndConfig({ mitra_enabled, customer_enabled })
|
const config = await setEarlyEndConfig({ mitra_enabled, customer_enabled })
|
||||||
return reply.send({ success: true, data: config })
|
return reply.send({ success: true, data: config })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Price Tiers ---
|
||||||
|
app.get('/price-tiers', {
|
||||||
|
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { getPriceTiers } = await import('../../services/pricing.service.js')
|
||||||
|
const tiers = await getPriceTiers()
|
||||||
|
return reply.send({ success: true, data: tiers })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/price-tiers', {
|
||||||
|
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { tiers } = request.body ?? {}
|
||||||
|
if (!Array.isArray(tiers) || tiers.length === 0) {
|
||||||
|
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'tiers must be a non-empty array' } })
|
||||||
|
}
|
||||||
|
for (const t of tiers) {
|
||||||
|
if (!t.duration_minutes || t.price === undefined || !t.label) {
|
||||||
|
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'Each tier needs duration_minutes, price, and label' } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { getDb } = await import('../../db/client.js')
|
||||||
|
const sql = getDb()
|
||||||
|
await sql`UPDATE app_config SET value = ${sql.json({ tiers })}, updated_at = NOW() WHERE key = 'price_tiers'`
|
||||||
|
return reply.send({ success: true, data: tiers })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +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 } from '../../services/pairing.service.js'
|
||||||
import { getActiveSessionByCustomer, endSession, getCustomerHistory } from '../../services/session.service.js'
|
import { getActiveSessionByCustomer, endSession, getCustomerHistory } from '../../services/session.service.js'
|
||||||
import { subscribe } from '../../plugins/valkey.js'
|
|
||||||
import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js'
|
import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js'
|
||||||
import { requestExtension } from '../../services/extension.service.js'
|
import { requestExtension } from '../../services/extension.service.js'
|
||||||
|
import { EndedBy } from '../../constants.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)
|
||||||
@@ -52,7 +52,7 @@ export const clientChatRoutes = async (app) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidTier(duration_minutes, price)) {
|
if (!(await isValidTier(duration_minutes, price))) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
|
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
|
||||||
@@ -63,43 +63,6 @@ export const clientChatRoutes = async (app) => {
|
|||||||
return reply.code(201).send({ success: true, data: session })
|
return reply.code(201).send({ success: true, data: session })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/request/:sessionId/status', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
|
||||||
const { sessionId } = request.params
|
|
||||||
|
|
||||||
// SSE stream for real-time status updates
|
|
||||||
reply.raw.writeHead(200, {
|
|
||||||
'Content-Type': 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Send current status immediately
|
|
||||||
const current = await getSessionStatus(sessionId)
|
|
||||||
if (current) {
|
|
||||||
reply.raw.write(`data: ${JSON.stringify(current)}\n\n`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If already in a terminal state, close
|
|
||||||
if (current && ['active', 'completed', 'cancelled', 'expired'].includes(current.status)) {
|
|
||||||
reply.raw.end()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to status updates
|
|
||||||
const unsubscribe = subscribe(`session:${sessionId}:status`, (data) => {
|
|
||||||
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`)
|
|
||||||
if (['paired', 'expired', 'session_ended'].includes(data.type)) {
|
|
||||||
reply.raw.end()
|
|
||||||
unsubscribe()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Clean up on client disconnect
|
|
||||||
request.raw.on('close', () => {
|
|
||||||
unsubscribe()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post('/request/:sessionId/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
app.post('/request/:sessionId/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
const session = await cancelPairingRequest(request.params.sessionId, request.customer.id)
|
const session = await cancelPairingRequest(request.params.sessionId, request.customer.id)
|
||||||
return reply.send({ success: true, data: session })
|
return reply.send({ success: true, data: session })
|
||||||
@@ -111,7 +74,7 @@ export const clientChatRoutes = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.post('/session/:sessionId/end', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
app.post('/session/:sessionId/end', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
const session = await endSession(request.params.sessionId, 'customer')
|
const session = await endSession(request.params.sessionId, EndedBy.CUSTOMER, request.customer.id)
|
||||||
return reply.send({ success: true, data: session })
|
return reply.send({ success: true, data: session })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ 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, getMitraHistory } from '../../services/session.service.js'
|
import { getActiveSessionsByMitra, endSession, getMitraHistory } from '../../services/session.service.js'
|
||||||
import { subscribe } from '../../plugins/valkey.js'
|
|
||||||
import { respondToExtension } from '../../services/extension.service.js'
|
import { respondToExtension } from '../../services/extension.service.js'
|
||||||
|
import { EndedBy } from '../../constants.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)
|
||||||
@@ -23,31 +23,6 @@ const resolveMitra = async (request, reply) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const mitraChatRoutes = async (app) => {
|
export const mitraChatRoutes = async (app) => {
|
||||||
app.get('/incoming', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
|
||||||
const mitraId = request.mitra.id
|
|
||||||
|
|
||||||
// SSE stream for incoming chat requests
|
|
||||||
reply.raw.writeHead(200, {
|
|
||||||
'Content-Type': 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Keep-alive ping
|
|
||||||
const pingInterval = setInterval(() => {
|
|
||||||
reply.raw.write(': ping\n\n')
|
|
||||||
}, 15_000)
|
|
||||||
|
|
||||||
const unsubscribe = subscribe(`mitra:${mitraId}:requests`, (data) => {
|
|
||||||
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`)
|
|
||||||
})
|
|
||||||
|
|
||||||
request.raw.on('close', () => {
|
|
||||||
clearInterval(pingInterval)
|
|
||||||
unsubscribe()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post('/:sessionId/accept', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
app.post('/:sessionId/accept', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||||
const session = await acceptPairingRequest(request.params.sessionId, request.mitra.id)
|
const session = await acceptPairingRequest(request.params.sessionId, request.mitra.id)
|
||||||
return reply.send({ success: true, data: session })
|
return reply.send({ success: true, data: session })
|
||||||
@@ -64,7 +39,7 @@ export const mitraChatRoutes = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.post('/sessions/:sessionId/end', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
app.post('/sessions/:sessionId/end', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||||
const session = await endSession(request.params.sessionId, 'mitra')
|
const session = await endSession(request.params.sessionId, EndedBy.MITRA, request.mitra.id)
|
||||||
return reply.send({ success: true, data: session })
|
return reply.send({ success: true, data: session })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,21 @@ import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
|
|||||||
import { getMessages } from '../../services/chat.service.js'
|
import { getMessages } from '../../services/chat.service.js'
|
||||||
import { getSessionClosures } from '../../services/closure.service.js'
|
import { getSessionClosures } from '../../services/closure.service.js'
|
||||||
import { registerDeviceToken } from '../../services/notification.service.js'
|
import { registerDeviceToken } from '../../services/notification.service.js'
|
||||||
|
import { getDb } from '../../db/client.js'
|
||||||
|
import { UserType } from '../../constants.js'
|
||||||
|
|
||||||
|
const sql = getDb()
|
||||||
|
|
||||||
const resolveUser = async (request, reply) => {
|
const resolveUser = async (request, reply) => {
|
||||||
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
|
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
|
||||||
if (customer) {
|
if (customer) {
|
||||||
request.userType = 'customer'
|
request.userType = UserType.CUSTOMER
|
||||||
request.userId = customer.id
|
request.userId = customer.id
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid)
|
const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid)
|
||||||
if (mitra) {
|
if (mitra) {
|
||||||
request.userType = 'mitra'
|
request.userType = UserType.MITRA
|
||||||
request.userId = mitra.id
|
request.userId = mitra.id
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -24,9 +28,25 @@ const resolveUser = async (request, reply) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify session belongs to the authenticated user
|
||||||
|
const verifySessionOwnership = async (request, reply) => {
|
||||||
|
const { sessionId } = request.params
|
||||||
|
const [session] = await sql`
|
||||||
|
SELECT id FROM chat_sessions
|
||||||
|
WHERE id = ${sessionId}
|
||||||
|
AND (customer_id = ${request.userId} OR mitra_id = ${request.userId})
|
||||||
|
`
|
||||||
|
if (!session) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
success: false,
|
||||||
|
error: { code: 'FORBIDDEN', message: 'You do not have access to this session' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const sharedChatRoutes = async (app) => {
|
export const sharedChatRoutes = async (app) => {
|
||||||
// Get messages for a session (paginated)
|
// Get messages for a session (paginated)
|
||||||
app.get('/chat/:sessionId/messages', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
|
app.get('/chat/:sessionId/messages', { preHandler: [authenticate, resolveUser, verifySessionOwnership] }, async (request, reply) => {
|
||||||
const { sessionId } = request.params
|
const { sessionId } = request.params
|
||||||
const { limit, before } = request.query
|
const { limit, before } = request.query
|
||||||
const messages = await getMessages(sessionId, {
|
const messages = await getMessages(sessionId, {
|
||||||
@@ -37,7 +57,7 @@ export const sharedChatRoutes = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Get session info
|
// Get session info
|
||||||
app.get('/chat/:sessionId/info', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
|
app.get('/chat/:sessionId/info', { preHandler: [authenticate, resolveUser, verifySessionOwnership] }, async (request, reply) => {
|
||||||
const { sessionId } = request.params
|
const { sessionId } = request.params
|
||||||
const { getSessionById } = await import('../../services/session.service.js')
|
const { getSessionById } = await import('../../services/session.service.js')
|
||||||
const session = await getSessionById(sessionId)
|
const session = await getSessionById(sessionId)
|
||||||
@@ -48,7 +68,7 @@ export const sharedChatRoutes = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Get full transcript (read-only, for history)
|
// Get full transcript (read-only, for history)
|
||||||
app.get('/chat/:sessionId/transcript', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
|
app.get('/chat/:sessionId/transcript', { preHandler: [authenticate, resolveUser, verifySessionOwnership] }, async (request, reply) => {
|
||||||
const { sessionId } = request.params
|
const { sessionId } = request.params
|
||||||
const messages = await getMessages(sessionId, { limit: 10000 })
|
const messages = await getMessages(sessionId, { limit: 10000 })
|
||||||
const closures = await getSessionClosures(sessionId)
|
const closures = await getSessionClosures(sessionId)
|
||||||
@@ -66,7 +86,7 @@ export const sharedChatRoutes = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Submit goodbye/closure message
|
// Submit goodbye/closure message
|
||||||
app.post('/sessions/:sessionId/close-message', { preHandler: [authenticate, resolveUser] }, async (request, reply) => {
|
app.post('/sessions/:sessionId/close-message', { preHandler: [authenticate, resolveUser, verifySessionOwnership] }, async (request, reply) => {
|
||||||
const { sessionId } = request.params
|
const { sessionId } = request.params
|
||||||
const { message } = request.body
|
const { message } = request.body
|
||||||
if (!message) {
|
if (!message) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { subscribe } from '../plugins/valkey.js'
|
|||||||
import { sendMessage, markDelivered, markRead } from './chat.service.js'
|
import { sendMessage, markDelivered, markRead } from './chat.service.js'
|
||||||
import { initiateEarlyEnd } from './closure.service.js'
|
import { initiateEarlyEnd } from './closure.service.js'
|
||||||
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||||
|
import { UserType, MessageType, WsMessage } from '../constants.js'
|
||||||
|
|
||||||
// Track typing throttle per session+user
|
// Track typing throttle per session+user
|
||||||
const typingLastSent = new Map()
|
const typingLastSent = new Map()
|
||||||
@@ -18,36 +19,36 @@ export const startSessionListener = (sessionId) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'message':
|
case WsMessage.MESSAGE:
|
||||||
await sendMessage({
|
await sendMessage({
|
||||||
sessionId: _session_id,
|
sessionId: _session_id,
|
||||||
senderType: _sender_type,
|
senderType: _sender_type,
|
||||||
senderId: _sender_id,
|
senderId: _sender_id,
|
||||||
content: payload.content,
|
content: payload.content,
|
||||||
type: payload.message_type || 'text',
|
type: payload.message_type || MessageType.TEXT,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'typing':
|
case WsMessage.TYPING:
|
||||||
handleTyping(_session_id, _sender_type)
|
handleTyping(_session_id, _sender_type)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'delivered':
|
case WsMessage.DELIVERED:
|
||||||
await markDelivered(_session_id, _sender_type, payload.message_ids)
|
await markDelivered(_session_id, _sender_type, payload.message_ids)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'read':
|
case WsMessage.READ:
|
||||||
await markRead(_session_id, _sender_type, payload.message_ids)
|
await markRead(_session_id, _sender_type, payload.message_ids)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'early_end':
|
case WsMessage.EARLY_END:
|
||||||
await initiateEarlyEnd(_session_id, _sender_type)
|
await initiateEarlyEnd(_session_id, _sender_type)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[chat-handler] Error processing ${type}:`, err.message)
|
console.error(`[chat-handler] Error processing ${type}:`, err.message)
|
||||||
sendToSessionParticipant(_session_id, _sender_type, {
|
sendToSessionParticipant(_session_id, _sender_type, {
|
||||||
type: 'error',
|
type: WsMessage.ERROR,
|
||||||
message: err.message,
|
message: err.message,
|
||||||
code: err.code,
|
code: err.code,
|
||||||
})
|
})
|
||||||
@@ -74,9 +75,9 @@ const handleTyping = (sessionId, senderType) => {
|
|||||||
|
|
||||||
typingLastSent.set(key, now)
|
typingLastSent.set(key, now)
|
||||||
|
|
||||||
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
|
const recipientType = senderType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER
|
||||||
sendToSessionParticipant(sessionId, recipientType, {
|
sendToSessionParticipant(sessionId, recipientType, {
|
||||||
type: 'typing',
|
type: WsMessage.TYPING,
|
||||||
sender_type: senderType,
|
sender_type: senderType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import { getDb } from '../db/client.js'
|
|||||||
import { publish } from '../plugins/valkey.js'
|
import { publish } from '../plugins/valkey.js'
|
||||||
import { isUserOnlineWs, sendToSessionParticipant } from '../plugins/websocket.js'
|
import { isUserOnlineWs, sendToSessionParticipant } from '../plugins/websocket.js'
|
||||||
import { sendPushNotification } from './notification.service.js'
|
import { sendPushNotification } from './notification.service.js'
|
||||||
|
import { UserType, SessionStatus, MessageStatus, MessageType, WsMessage } from '../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
export const sendMessage = async ({ sessionId, senderType, senderId, content, type = 'text' }) => {
|
export const sendMessage = async ({ sessionId, senderType, senderId, content, type = MessageType.TEXT }) => {
|
||||||
// Verify session is active
|
// Verify session is active
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
SELECT id, customer_id, mitra_id, status FROM chat_sessions
|
SELECT id, customer_id, mitra_id, status FROM chat_sessions
|
||||||
WHERE id = ${sessionId} AND status = 'active'
|
WHERE id = ${sessionId} AND status = ${SessionStatus.ACTIVE}
|
||||||
`
|
`
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw Object.assign(new Error('Session is not active'), {
|
throw Object.assign(new Error('Session is not active'), {
|
||||||
@@ -20,25 +21,25 @@ export const sendMessage = async ({ sessionId, senderType, senderId, content, ty
|
|||||||
// Save message
|
// Save message
|
||||||
const [message] = await sql`
|
const [message] = await sql`
|
||||||
INSERT INTO chat_messages (session_id, sender_type, sender_id, type, content, status)
|
INSERT INTO chat_messages (session_id, sender_type, sender_id, type, content, status)
|
||||||
VALUES (${sessionId}, ${senderType}, ${senderId}, ${type}, ${content}, 'sent')
|
VALUES (${sessionId}, ${senderType}, ${senderId}, ${type}, ${content}, ${MessageStatus.SENT})
|
||||||
RETURNING id, session_id, sender_type, sender_id, type, content, status, created_at
|
RETURNING id, session_id, sender_type, sender_id, type, content, status, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
// Send ack to sender
|
// Send ack to sender
|
||||||
sendToSessionParticipant(sessionId, senderType, {
|
sendToSessionParticipant(sessionId, senderType, {
|
||||||
type: 'message_ack',
|
type: WsMessage.MESSAGE_ACK,
|
||||||
message_id: message.id,
|
message_id: message.id,
|
||||||
status: 'sent',
|
status: MessageStatus.SENT,
|
||||||
created_at: message.created_at,
|
created_at: message.created_at,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Determine recipient
|
// Determine recipient
|
||||||
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
|
const recipientType = senderType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER
|
||||||
const recipientId = senderType === 'customer' ? session.mitra_id : session.customer_id
|
const recipientId = senderType === UserType.CUSTOMER ? session.mitra_id : session.customer_id
|
||||||
|
|
||||||
// Try to send via WebSocket
|
// Try to send via WebSocket
|
||||||
const delivered = sendToSessionParticipant(sessionId, recipientType, {
|
const delivered = sendToSessionParticipant(sessionId, recipientType, {
|
||||||
type: 'message',
|
type: WsMessage.MESSAGE,
|
||||||
message_id: message.id,
|
message_id: message.id,
|
||||||
sender_type: senderType,
|
sender_type: senderType,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
@@ -49,7 +50,7 @@ export const sendMessage = async ({ sessionId, senderType, senderId, content, ty
|
|||||||
// If recipient not connected via WebSocket, send FCM push
|
// If recipient not connected via WebSocket, send FCM push
|
||||||
if (!delivered && recipientId) {
|
if (!delivered && recipientId) {
|
||||||
await sendPushNotification(recipientType, recipientId, {
|
await sendPushNotification(recipientType, recipientId, {
|
||||||
title: senderType === 'customer' ? 'Pesan baru dari Customer' : 'Pesan baru dari Bestie',
|
title: senderType === UserType.CUSTOMER ? 'Pesan baru dari Customer' : 'Pesan baru dari Bestie',
|
||||||
body: content.length > 100 ? content.substring(0, 100) + '...' : content,
|
body: content.length > 100 ? content.substring(0, 100) + '...' : content,
|
||||||
data: { session_id: sessionId, type: 'chat_message' },
|
data: { session_id: sessionId, type: 'chat_message' },
|
||||||
})
|
})
|
||||||
@@ -63,18 +64,18 @@ export const markDelivered = async (sessionId, senderType, messageIds) => {
|
|||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE chat_messages
|
UPDATE chat_messages
|
||||||
SET status = 'delivered', delivered_at = NOW()
|
SET status = ${MessageStatus.DELIVERED}, delivered_at = NOW()
|
||||||
WHERE id = ANY(${messageIds})
|
WHERE id = ANY(${messageIds})
|
||||||
AND session_id = ${sessionId}
|
AND session_id = ${sessionId}
|
||||||
AND status = 'sent'
|
AND status = ${MessageStatus.SENT}
|
||||||
`
|
`
|
||||||
|
|
||||||
// Notify sender about delivery
|
// Notify sender about delivery
|
||||||
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
|
const recipientType = senderType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER
|
||||||
sendToSessionParticipant(sessionId, recipientType, {
|
sendToSessionParticipant(sessionId, recipientType, {
|
||||||
type: 'message_status',
|
type: WsMessage.MESSAGE_STATUS,
|
||||||
message_ids: messageIds,
|
message_ids: messageIds,
|
||||||
status: 'delivered',
|
status: MessageStatus.DELIVERED,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,18 +84,18 @@ export const markRead = async (sessionId, senderType, messageIds) => {
|
|||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE chat_messages
|
UPDATE chat_messages
|
||||||
SET status = 'read', read_at = NOW()
|
SET status = ${MessageStatus.READ}, read_at = NOW()
|
||||||
WHERE id = ANY(${messageIds})
|
WHERE id = ANY(${messageIds})
|
||||||
AND session_id = ${sessionId}
|
AND session_id = ${sessionId}
|
||||||
AND status IN ('sent', 'delivered')
|
AND status IN (${MessageStatus.SENT}, ${MessageStatus.DELIVERED})
|
||||||
`
|
`
|
||||||
|
|
||||||
// Notify sender about read
|
// Notify sender about read
|
||||||
const recipientType = senderType === 'customer' ? 'mitra' : 'customer'
|
const recipientType = senderType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER
|
||||||
sendToSessionParticipant(sessionId, recipientType, {
|
sendToSessionParticipant(sessionId, recipientType, {
|
||||||
type: 'message_status',
|
type: WsMessage.MESSAGE_STATUS,
|
||||||
message_ids: messageIds,
|
message_ids: messageIds,
|
||||||
status: 'read',
|
status: MessageStatus.READ,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,13 +116,13 @@ export const getMessages = async (sessionId, { limit = 50, before } = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getUndeliveredMessages = async (sessionId, recipientType) => {
|
export const getUndeliveredMessages = async (sessionId, recipientType) => {
|
||||||
const senderType = recipientType === 'customer' ? 'mitra' : 'customer'
|
const senderType = recipientType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER
|
||||||
return sql`
|
return sql`
|
||||||
SELECT id, session_id, sender_type, sender_id, type, content, status, created_at
|
SELECT id, session_id, sender_type, sender_id, type, content, status, created_at
|
||||||
FROM chat_messages
|
FROM chat_messages
|
||||||
WHERE session_id = ${sessionId}
|
WHERE session_id = ${sessionId}
|
||||||
AND sender_type = ${senderType}
|
AND sender_type = ${senderType}
|
||||||
AND status = 'sent'
|
AND status = ${MessageStatus.SENT}
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { getDb } from '../db/client.js'
|
import { getDb } from '../db/client.js'
|
||||||
import { publish } from '../plugins/valkey.js'
|
import { publish } from '../plugins/valkey.js'
|
||||||
import { clearSessionTimer } from './session-timer.service.js'
|
import { clearSessionTimer, clearClosureGraceTimer } from './session-timer.service.js'
|
||||||
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||||
|
import { UserType, SessionStatus, EndedBy, WsMessage } from '../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ export const submitClosureMessage = async (sessionId, userType, userId, message)
|
|||||||
// Verify session is in closing or active state (for early end)
|
// Verify session is in closing or active state (for early end)
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
SELECT id, status FROM chat_sessions
|
SELECT id, status FROM chat_sessions
|
||||||
WHERE id = ${sessionId} AND status IN ('closing', 'active', 'extending')
|
WHERE id = ${sessionId} AND status IN (${SessionStatus.CLOSING}, ${SessionStatus.ACTIVE}, ${SessionStatus.EXTENDING})
|
||||||
`
|
`
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw Object.assign(new Error('Session not found or already completed'), {
|
throw Object.assign(new Error('Session not found or already completed'), {
|
||||||
@@ -29,8 +30,8 @@ export const submitClosureMessage = async (sessionId, userType, userId, message)
|
|||||||
const closures = await sql`
|
const closures = await sql`
|
||||||
SELECT user_type FROM session_closures WHERE session_id = ${sessionId}
|
SELECT user_type FROM session_closures WHERE session_id = ${sessionId}
|
||||||
`
|
`
|
||||||
const hasCustomer = closures.some((c) => c.user_type === 'customer')
|
const hasCustomer = closures.some((c) => c.user_type === UserType.CUSTOMER)
|
||||||
const hasMitra = closures.some((c) => c.user_type === 'mitra')
|
const hasMitra = closures.some((c) => c.user_type === UserType.MITRA)
|
||||||
|
|
||||||
if (hasCustomer && hasMitra) {
|
if (hasCustomer && hasMitra) {
|
||||||
// Both submitted — complete the session
|
// Both submitted — complete the session
|
||||||
@@ -42,28 +43,29 @@ export const submitClosureMessage = async (sessionId, userType, userId, message)
|
|||||||
|
|
||||||
export const completeSession = async (sessionId) => {
|
export const completeSession = async (sessionId) => {
|
||||||
clearSessionTimer(sessionId)
|
clearSessionTimer(sessionId)
|
||||||
|
clearClosureGraceTimer(sessionId)
|
||||||
|
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
UPDATE chat_sessions
|
UPDATE chat_sessions
|
||||||
SET status = 'completed', ended_at = NOW(), ended_by = 'system'
|
SET status = ${SessionStatus.COMPLETED}, ended_at = NOW(), ended_by = ${EndedBy.SYSTEM}
|
||||||
WHERE id = ${sessionId} AND status IN ('closing', 'active', 'extending')
|
WHERE id = ${sessionId} AND status IN (${SessionStatus.CLOSING}, ${SessionStatus.ACTIVE}, ${SessionStatus.EXTENDING})
|
||||||
RETURNING id, customer_id, mitra_id, status, ended_at
|
RETURNING id, customer_id, mitra_id, status, ended_at
|
||||||
`
|
`
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
|
|
||||||
// Notify both parties
|
// Notify both parties
|
||||||
const data = { type: 'session_completed', session_id: sessionId }
|
const data = { type: WsMessage.SESSION_COMPLETED, session_id: sessionId }
|
||||||
sendToSessionParticipant(sessionId, 'customer', data)
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
|
||||||
sendToSessionParticipant(sessionId, 'mitra', data)
|
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
||||||
|
|
||||||
await publish(`session:${sessionId}:status`, { type: 'session_ended', session_id: sessionId })
|
await publish(`session:${sessionId}:status`, { type: WsMessage.SESSION_ENDED, session_id: sessionId })
|
||||||
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initiateEarlyEnd = async (sessionId, userType) => {
|
export const initiateEarlyEnd = async (sessionId, userType) => {
|
||||||
// Check if early end is enabled for this user type
|
// Check if early end is enabled for this user type
|
||||||
const configKey = userType === 'mitra' ? 'early_end_mitra_enabled' : 'early_end_customer_enabled'
|
const configKey = userType === UserType.MITRA ? 'early_end_mitra_enabled' : 'early_end_customer_enabled'
|
||||||
const [configRow] = await sql`SELECT value FROM app_config WHERE key = ${configKey}`
|
const [configRow] = await sql`SELECT value FROM app_config WHERE key = ${configKey}`
|
||||||
const enabled = configRow?.value?.value ?? false
|
const enabled = configRow?.value?.value ?? false
|
||||||
|
|
||||||
@@ -76,8 +78,8 @@ export const initiateEarlyEnd = async (sessionId, userType) => {
|
|||||||
// Move session to closing
|
// Move session to closing
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
UPDATE chat_sessions
|
UPDATE chat_sessions
|
||||||
SET status = 'closing', ended_by = ${userType}
|
SET status = ${SessionStatus.CLOSING}, ended_by = ${userType}
|
||||||
WHERE id = ${sessionId} AND status = 'active'
|
WHERE id = ${sessionId} AND status = ${SessionStatus.ACTIVE}
|
||||||
RETURNING id, customer_id, mitra_id
|
RETURNING id, customer_id, mitra_id
|
||||||
`
|
`
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -89,9 +91,9 @@ export const initiateEarlyEnd = async (sessionId, userType) => {
|
|||||||
clearSessionTimer(sessionId)
|
clearSessionTimer(sessionId)
|
||||||
|
|
||||||
// Notify both parties to enter closure flow
|
// Notify both parties to enter closure flow
|
||||||
const data = { type: 'session_closing', session_id: sessionId, ended_by: userType }
|
const data = { type: WsMessage.SESSION_CLOSING, session_id: sessionId, ended_by: userType }
|
||||||
sendToSessionParticipant(sessionId, 'customer', data)
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
|
||||||
sendToSessionParticipant(sessionId, 'mitra', data)
|
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
||||||
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { getDb } from '../db/client.js'
|
import { getDb } from '../db/client.js'
|
||||||
|
import { SessionStatus } from '../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
export const getDashboardStats = async () => {
|
export const getDashboardStats = async () => {
|
||||||
const [[{ active_chats }], [{ online_mitras }], [{ pending_requests }]] = await Promise.all([
|
const [[{ active_chats }], [{ online_mitras }], [{ pending_requests }]] = await Promise.all([
|
||||||
sql`SELECT COUNT(*) AS active_chats FROM chat_sessions WHERE status IN ('active', 'pending_payment')`,
|
sql`SELECT COUNT(*) AS active_chats FROM chat_sessions WHERE status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})`,
|
||||||
sql`SELECT COUNT(*) AS online_mitras FROM mitra_online_status WHERE is_online = true`,
|
sql`SELECT COUNT(*) AS online_mitras FROM mitra_online_status WHERE is_online = true`,
|
||||||
sql`SELECT COUNT(*) AS pending_requests FROM chat_sessions WHERE status IN ('searching', 'pending_acceptance')`,
|
sql`SELECT COUNT(*) AS pending_requests FROM chat_sessions WHERE status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})`,
|
||||||
])
|
])
|
||||||
|
|
||||||
const customersPerMitra = await sql`
|
const customersPerMitra = await sql`
|
||||||
SELECT m.id, m.display_name,
|
SELECT m.id, m.display_name,
|
||||||
(SELECT COUNT(*) FROM chat_sessions cs
|
(SELECT COUNT(*) FROM chat_sessions cs
|
||||||
WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment')) AS active_session_count
|
WHERE cs.mitra_id = m.id AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})) AS active_session_count
|
||||||
FROM mitras m
|
FROM mitras m
|
||||||
INNER JOIN mitra_online_status s ON s.mitra_id = m.id
|
INNER JOIN mitra_online_status s ON s.mitra_id = m.id
|
||||||
WHERE s.is_online = true
|
WHERE s.is_online = true
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { getDb } from '../db/client.js'
|
import { getDb } from '../db/client.js'
|
||||||
import { publish } from '../plugins/valkey.js'
|
import { publish } from '../plugins/valkey.js'
|
||||||
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||||
import { extendSessionTimer } from './session-timer.service.js'
|
import { extendSessionTimer, clearClosureGraceTimer } from './session-timer.service.js'
|
||||||
|
import { UserType, SessionStatus, ExtensionStatus, TransactionType, WsMessage } from '../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
@@ -14,28 +15,29 @@ const getExtensionTimeout = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const requestExtension = async (sessionId, customerId, { duration_minutes, price }) => {
|
export const requestExtension = async (sessionId, customerId, { duration_minutes, price }) => {
|
||||||
// Verify session belongs to customer and just expired
|
// Verify session belongs to customer and is in an extendable state
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
SELECT id, customer_id, mitra_id, status FROM chat_sessions
|
SELECT id, customer_id, mitra_id, status FROM chat_sessions
|
||||||
WHERE id = ${sessionId} AND customer_id = ${customerId}
|
WHERE id = ${sessionId} AND customer_id = ${customerId}
|
||||||
|
AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.CLOSING})
|
||||||
`
|
`
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw Object.assign(new Error('Session not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
throw Object.assign(new Error('Session not found or already ended'), { code: 'SESSION_NOT_ACTIVE', statusCode: 409 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create extension record
|
// Create extension record
|
||||||
const [extension] = await sql`
|
const [extension] = await sql`
|
||||||
INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status)
|
INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status)
|
||||||
VALUES (${sessionId}, ${duration_minutes}, ${price}, 'pending')
|
VALUES (${sessionId}, ${duration_minutes}, ${price}, ${ExtensionStatus.PENDING})
|
||||||
RETURNING id, session_id, requested_duration_minutes, requested_price, status, requested_at
|
RETURNING id, session_id, requested_duration_minutes, requested_price, status, requested_at
|
||||||
`
|
`
|
||||||
|
|
||||||
// Pause the session
|
// Pause the session
|
||||||
await sql`UPDATE chat_sessions SET status = 'extending' WHERE id = ${sessionId}`
|
await sql`UPDATE chat_sessions SET status = ${SessionStatus.EXTENDING} WHERE id = ${sessionId}`
|
||||||
|
|
||||||
// Notify mitra
|
// Notify mitra
|
||||||
sendToSessionParticipant(sessionId, 'mitra', {
|
sendToSessionParticipant(sessionId, UserType.MITRA, {
|
||||||
type: 'extension_request',
|
type: WsMessage.EXTENSION_REQUEST,
|
||||||
extension_id: extension.id,
|
extension_id: extension.id,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
duration_minutes,
|
duration_minutes,
|
||||||
@@ -43,8 +45,8 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Notify customer that chat is paused
|
// Notify customer that chat is paused
|
||||||
sendToSessionParticipant(sessionId, 'customer', {
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
|
||||||
type: 'session_paused',
|
type: WsMessage.SESSION_PAUSED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
reason: 'extension_pending',
|
reason: 'extension_pending',
|
||||||
})
|
})
|
||||||
@@ -62,12 +64,20 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const respondToExtension = async (extensionId, sessionId, mitraId, accepted) => {
|
export const respondToExtension = async (extensionId, sessionId, mitraId, accepted) => {
|
||||||
const status = accepted ? 'accepted' : 'rejected'
|
// Verify session belongs to this mitra
|
||||||
|
const [session] = await sql`
|
||||||
|
SELECT id FROM chat_sessions WHERE id = ${sessionId} AND mitra_id = ${mitraId}
|
||||||
|
`
|
||||||
|
if (!session) {
|
||||||
|
throw Object.assign(new Error('Session not found'), { code: 'FORBIDDEN', statusCode: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = accepted ? ExtensionStatus.ACCEPTED : ExtensionStatus.REJECTED
|
||||||
|
|
||||||
const [extension] = await sql`
|
const [extension] = await sql`
|
||||||
UPDATE session_extensions
|
UPDATE session_extensions
|
||||||
SET status = ${status}, responded_at = NOW()
|
SET status = ${status}, responded_at = NOW()
|
||||||
WHERE id = ${extensionId} AND status = 'pending'
|
WHERE id = ${extensionId} AND session_id = ${sessionId} AND status = ${ExtensionStatus.PENDING}
|
||||||
RETURNING id, session_id, requested_duration_minutes, requested_price, status
|
RETURNING id, session_id, requested_duration_minutes, requested_price, status
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -85,43 +95,50 @@ export const respondToExtension = async (extensionId, sessionId, mitraId, accept
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
|
// Clear any pending grace timer from the previous expiry
|
||||||
|
clearClosureGraceTimer(sessionId)
|
||||||
|
|
||||||
// Extend the session
|
// Extend the session
|
||||||
await extendSessionTimer(extension.session_id, extension.requested_duration_minutes)
|
await extendSessionTimer(extension.session_id, extension.requested_duration_minutes)
|
||||||
|
|
||||||
// Resume session
|
// Resume session
|
||||||
await sql`UPDATE chat_sessions SET status = 'active' WHERE id = ${extension.session_id}`
|
await sql`UPDATE chat_sessions SET status = ${SessionStatus.ACTIVE} WHERE id = ${extension.session_id}`
|
||||||
|
|
||||||
// Record transaction
|
// Record transaction
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
|
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
|
||||||
SELECT customer_id, id, 'extension', ${extension.requested_price}
|
SELECT customer_id, id, ${TransactionType.EXTENSION}, ${extension.requested_price}
|
||||||
FROM chat_sessions WHERE id = ${extension.session_id}
|
FROM chat_sessions WHERE id = ${extension.session_id}
|
||||||
`
|
`
|
||||||
|
|
||||||
// Notify both parties
|
// Notify both parties
|
||||||
sendToSessionParticipant(sessionId, 'customer', {
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
|
||||||
type: 'extension_response',
|
type: WsMessage.EXTENSION_RESPONSE,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
duration_minutes: extension.requested_duration_minutes,
|
duration_minutes: extension.requested_duration_minutes,
|
||||||
})
|
})
|
||||||
sendToSessionParticipant(sessionId, 'mitra', {
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
|
||||||
type: 'session_resumed',
|
type: WsMessage.SESSION_RESUMED,
|
||||||
|
session_id: sessionId,
|
||||||
|
})
|
||||||
|
sendToSessionParticipant(sessionId, UserType.MITRA, {
|
||||||
|
type: WsMessage.SESSION_RESUMED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Rejected — proceed to closure
|
// Rejected — proceed to closure
|
||||||
await sql`UPDATE chat_sessions SET status = 'closing' WHERE id = ${extension.session_id}`
|
await sql`UPDATE chat_sessions SET status = ${SessionStatus.CLOSING} WHERE id = ${extension.session_id}`
|
||||||
|
|
||||||
sendToSessionParticipant(sessionId, 'customer', {
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
|
||||||
type: 'extension_response',
|
type: WsMessage.EXTENSION_RESPONSE,
|
||||||
accepted: false,
|
accepted: false,
|
||||||
})
|
})
|
||||||
sendToSessionParticipant(sessionId, 'mitra', {
|
sendToSessionParticipant(sessionId, UserType.MITRA, {
|
||||||
type: 'session_closing',
|
type: WsMessage.SESSION_CLOSING,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
})
|
})
|
||||||
sendToSessionParticipant(sessionId, 'customer', {
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
|
||||||
type: 'session_closing',
|
type: WsMessage.SESSION_CLOSING,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -134,26 +151,26 @@ const timeoutExtension = async (extensionId, sessionId) => {
|
|||||||
|
|
||||||
const [extension] = await sql`
|
const [extension] = await sql`
|
||||||
UPDATE session_extensions
|
UPDATE session_extensions
|
||||||
SET status = 'timeout', responded_at = NOW()
|
SET status = ${ExtensionStatus.TIMEOUT}, responded_at = NOW()
|
||||||
WHERE id = ${extensionId} AND status = 'pending'
|
WHERE id = ${extensionId} AND status = ${ExtensionStatus.PENDING}
|
||||||
RETURNING id, session_id
|
RETURNING id, session_id
|
||||||
`
|
`
|
||||||
if (!extension) return
|
if (!extension) return
|
||||||
|
|
||||||
// Timeout = proceed to closure
|
// Timeout = proceed to closure
|
||||||
await sql`UPDATE chat_sessions SET status = 'closing' WHERE id = ${extension.session_id}`
|
await sql`UPDATE chat_sessions SET status = ${SessionStatus.CLOSING} WHERE id = ${extension.session_id}`
|
||||||
|
|
||||||
sendToSessionParticipant(sessionId, 'customer', {
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
|
||||||
type: 'extension_response',
|
type: WsMessage.EXTENSION_RESPONSE,
|
||||||
accepted: false,
|
accepted: false,
|
||||||
reason: 'timeout',
|
reason: 'timeout',
|
||||||
})
|
})
|
||||||
sendToSessionParticipant(sessionId, 'mitra', {
|
sendToSessionParticipant(sessionId, UserType.MITRA, {
|
||||||
type: 'session_closing',
|
type: WsMessage.SESSION_CLOSING,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
})
|
})
|
||||||
sendToSessionParticipant(sessionId, 'customer', {
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
|
||||||
type: 'session_closing',
|
type: WsMessage.SESSION_CLOSING,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getDb } from '../db/client.js'
|
import { getDb } from '../db/client.js'
|
||||||
|
import { SessionStatus } from '../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ export const getOnlineMitras = async () => {
|
|||||||
const mitras = await sql`
|
const mitras = await sql`
|
||||||
SELECT m.id, m.display_name, m.phone, s.last_online_at, s.updated_at,
|
SELECT m.id, m.display_name, m.phone, s.last_online_at, s.updated_at,
|
||||||
(SELECT COUNT(*) FROM chat_sessions cs
|
(SELECT COUNT(*) FROM chat_sessions cs
|
||||||
WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment')) AS active_session_count
|
WHERE cs.mitra_id = m.id AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})) AS active_session_count
|
||||||
FROM mitras m
|
FROM mitras m
|
||||||
INNER JOIN mitra_online_status s ON s.mitra_id = m.id
|
INNER JOIN mitra_online_status s ON s.mitra_id = m.id
|
||||||
WHERE s.is_online = true AND m.is_active = true
|
WHERE s.is_online = true AND m.is_active = true
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import admin from 'firebase-admin'
|
import admin from 'firebase-admin'
|
||||||
import { getDb } from '../db/client.js'
|
import { getDb } from '../db/client.js'
|
||||||
|
import { UserType } from '../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
export const registerDeviceToken = async (userType, userId, fcmToken) => {
|
export const registerDeviceToken = async (userType, userId, fcmToken) => {
|
||||||
const table = userType === 'customer' ? 'customers' : 'mitras'
|
const table = userType === UserType.CUSTOMER ? 'customers' : 'mitras'
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE ${sql(table)}
|
UPDATE ${sql(table)}
|
||||||
SET fcm_token = ${fcmToken}
|
SET fcm_token = ${fcmToken}
|
||||||
@@ -13,7 +14,7 @@ export const registerDeviceToken = async (userType, userId, fcmToken) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const sendPushNotification = async (recipientType, recipientId, { title, body, data = {} }) => {
|
export const sendPushNotification = async (recipientType, recipientId, { title, body, data = {} }) => {
|
||||||
const table = recipientType === 'customer' ? 'customers' : 'mitras'
|
const table = recipientType === UserType.CUSTOMER ? 'customers' : 'mitras'
|
||||||
const [user] = await sql`
|
const [user] = await sql`
|
||||||
SELECT fcm_token FROM ${sql(table)} WHERE id = ${recipientId}
|
SELECT fcm_token FROM ${sql(table)} WHERE id = ${recipientId}
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -1,14 +1,51 @@
|
|||||||
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 { sendToUser } from '../plugins/websocket.js'
|
||||||
|
import { sendPushNotification } from './notification.service.js'
|
||||||
import { startSessionTimer } from './session-timer.service.js'
|
import { startSessionTimer } from './session-timer.service.js'
|
||||||
import { startSessionListener } from './chat-handler.service.js'
|
import { startSessionListener } from './chat-handler.service.js'
|
||||||
|
import { UserType, SessionStatus, NotificationResponse, TransactionType, WsMessage } from '../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
// Timeout map for active pairing requests (sessionId → timeoutId)
|
// Timeout map for active pairing requests (sessionId → timeoutId)
|
||||||
const pairingTimeouts = new Map()
|
const pairingTimeouts = new Map()
|
||||||
|
|
||||||
|
// Send notification to mitra via WebSocket, fall back to FCM if offline
|
||||||
|
const notifyMitra = async (mitraId, data) => {
|
||||||
|
const sent = sendToUser(UserType.MITRA, mitraId, data)
|
||||||
|
if (!sent) {
|
||||||
|
// Mitra not connected via WebSocket — send FCM push
|
||||||
|
if (data.type === WsMessage.CHAT_REQUEST) {
|
||||||
|
await sendPushNotification(UserType.MITRA, mitraId, {
|
||||||
|
title: 'Permintaan Chat Baru',
|
||||||
|
body: 'Ada pelanggan yang ingin curhat!',
|
||||||
|
data: { type: WsMessage.CHAT_REQUEST, session_id: data.session_id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notification to customer via WebSocket, fall back to FCM if offline
|
||||||
|
const notifyCustomer = async (customerId, data) => {
|
||||||
|
const sent = sendToUser(UserType.CUSTOMER, customerId, data)
|
||||||
|
if (!sent) {
|
||||||
|
if (data.type === WsMessage.PAIRED) {
|
||||||
|
await sendPushNotification(UserType.CUSTOMER, customerId, {
|
||||||
|
title: 'Bestie Ditemukan!',
|
||||||
|
body: `${data.mitra_display_name} siap menemanimu curhat`,
|
||||||
|
data: { type: WsMessage.PAIRED, session_id: data.session_id },
|
||||||
|
})
|
||||||
|
} else if (data.type === WsMessage.SESSION_EXPIRED) {
|
||||||
|
await sendPushNotification(UserType.CUSTOMER, customerId, {
|
||||||
|
title: 'Tidak Ada Bestie',
|
||||||
|
body: 'Maaf, tidak ada bestie yang tersedia saat ini.',
|
||||||
|
data: { type: WsMessage.SESSION_EXPIRED, session_id: data.session_id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const findAvailableMitras = async () => {
|
export const findAvailableMitras = async () => {
|
||||||
const { max_customers_per_mitra } = await getMaxCustomersPerMitra()
|
const { max_customers_per_mitra } = await getMaxCustomersPerMitra()
|
||||||
const mitras = await sql`
|
const mitras = await sql`
|
||||||
@@ -19,7 +56,7 @@ export const findAvailableMitras = async () => {
|
|||||||
AND s.is_online = true
|
AND s.is_online = true
|
||||||
AND (
|
AND (
|
||||||
SELECT COUNT(*) FROM chat_sessions cs
|
SELECT COUNT(*) FROM chat_sessions cs
|
||||||
WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment')
|
WHERE cs.mitra_id = m.id AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})
|
||||||
) < ${max_customers_per_mitra}
|
) < ${max_customers_per_mitra}
|
||||||
`
|
`
|
||||||
return mitras
|
return mitras
|
||||||
@@ -30,7 +67,7 @@ export const createPairingRequest = async (customerId, { duration_minutes, price
|
|||||||
const [existing] = await sql`
|
const [existing] = await sql`
|
||||||
SELECT id, status FROM chat_sessions
|
SELECT id, status FROM chat_sessions
|
||||||
WHERE customer_id = ${customerId}
|
WHERE customer_id = ${customerId}
|
||||||
AND status IN ('searching', 'pending_acceptance', 'pending_payment', 'active')
|
AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}, ${SessionStatus.PENDING_PAYMENT}, ${SessionStatus.ACTIVE})
|
||||||
`
|
`
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw Object.assign(new Error('Customer already has an active session or request'), {
|
throw Object.assign(new Error('Customer already has an active session or request'), {
|
||||||
@@ -48,7 +85,7 @@ export const createPairingRequest = async (customerId, { duration_minutes, price
|
|||||||
// Create session with duration/price
|
// Create session with duration/price
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price, is_free_trial)
|
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price, is_free_trial)
|
||||||
VALUES (${customerId}, 'pending_acceptance', ${duration_minutes || null}, ${price || 0}, ${is_free_trial || false})
|
VALUES (${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, ${duration_minutes || null}, ${price || 0}, ${is_free_trial || false})
|
||||||
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, created_at
|
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -58,9 +95,9 @@ export const createPairingRequest = async (customerId, { duration_minutes, price
|
|||||||
INSERT INTO chat_request_notifications (session_id, mitra_id)
|
INSERT INTO chat_request_notifications (session_id, mitra_id)
|
||||||
VALUES (${session.id}, ${mitra.id})
|
VALUES (${session.id}, ${mitra.id})
|
||||||
`
|
`
|
||||||
// Publish to mitra's channel
|
// Notify mitra via WebSocket (FCM fallback if offline)
|
||||||
await publish(`mitra:${mitra.id}:requests`, {
|
await notifyMitra(mitra.id, {
|
||||||
type: 'chat_request',
|
type: WsMessage.CHAT_REQUEST,
|
||||||
session_id: session.id,
|
session_id: session.id,
|
||||||
created_at: session.created_at,
|
created_at: session.created_at,
|
||||||
})
|
})
|
||||||
@@ -81,8 +118,8 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
|||||||
// Use a transaction-like approach: update only if status is still pending_acceptance
|
// Use a transaction-like approach: update only if status is still pending_acceptance
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
UPDATE chat_sessions
|
UPDATE chat_sessions
|
||||||
SET mitra_id = ${mitraId}, status = 'pending_payment', paired_at = NOW()
|
SET mitra_id = ${mitraId}, status = ${SessionStatus.PENDING_PAYMENT}, paired_at = NOW()
|
||||||
WHERE id = ${sessionId} AND status = 'pending_acceptance' AND mitra_id IS NULL
|
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} AND mitra_id IS NULL
|
||||||
RETURNING id, customer_id, mitra_id, status, paired_at
|
RETURNING id, customer_id, mitra_id, status, paired_at
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -95,14 +132,14 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
|||||||
// Mark this mitra's notification as accepted
|
// Mark this mitra's notification as accepted
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE chat_request_notifications
|
UPDATE chat_request_notifications
|
||||||
SET response = 'accepted', responded_at = NOW()
|
SET response = ${NotificationResponse.ACCEPTED}, responded_at = NOW()
|
||||||
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId}
|
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId}
|
||||||
`
|
`
|
||||||
|
|
||||||
// Mark other mitras' notifications as ignored
|
// Mark other mitras' notifications as ignored
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE chat_request_notifications
|
UPDATE chat_request_notifications
|
||||||
SET response = 'ignored', responded_at = NOW()
|
SET response = ${NotificationResponse.IGNORED}, responded_at = NOW()
|
||||||
WHERE session_id = ${sessionId} AND mitra_id != ${mitraId} AND response IS NULL
|
WHERE session_id = ${sessionId} AND mitra_id != ${mitraId} AND response IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -116,7 +153,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
|||||||
// Auto-skip payment for now: move to active and set expires_at
|
// Auto-skip payment for now: move to active and set expires_at
|
||||||
const [activeSession] = await sql`
|
const [activeSession] = await sql`
|
||||||
UPDATE chat_sessions
|
UPDATE chat_sessions
|
||||||
SET status = 'active',
|
SET status = ${SessionStatus.ACTIVE},
|
||||||
expires_at = CASE
|
expires_at = CASE
|
||||||
WHEN duration_minutes IS NOT NULL THEN NOW() + (duration_minutes || ' minutes')::interval
|
WHEN duration_minutes IS NOT NULL THEN NOW() + (duration_minutes || ' minutes')::interval
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
@@ -127,7 +164,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
|||||||
|
|
||||||
// Record transaction
|
// Record transaction
|
||||||
if (activeSession.duration_minutes) {
|
if (activeSession.duration_minutes) {
|
||||||
const txType = activeSession.is_free_trial ? 'free_trial' : 'paid'
|
const txType = activeSession.is_free_trial ? TransactionType.FREE_TRIAL : TransactionType.PAID
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
|
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
|
||||||
VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0})
|
VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0})
|
||||||
@@ -147,12 +184,12 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
|||||||
SELECT display_name FROM mitras WHERE id = ${mitraId}
|
SELECT display_name FROM mitras WHERE id = ${mitraId}
|
||||||
`
|
`
|
||||||
|
|
||||||
// Notify customer
|
// Notify customer via WebSocket (FCM fallback)
|
||||||
await publish(`session:${sessionId}:status`, {
|
await notifyCustomer(activeSession.customer_id, {
|
||||||
type: 'paired',
|
type: WsMessage.PAIRED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
mitra_display_name: mitra.display_name,
|
mitra_display_name: mitra.display_name,
|
||||||
status: 'active',
|
status: SessionStatus.ACTIVE,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify other mitras to dismiss the request
|
// Notify other mitras to dismiss the request
|
||||||
@@ -161,8 +198,8 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
|||||||
WHERE session_id = ${sessionId} AND mitra_id != ${mitraId}
|
WHERE session_id = ${sessionId} AND mitra_id != ${mitraId}
|
||||||
`
|
`
|
||||||
for (const n of notifications) {
|
for (const n of notifications) {
|
||||||
await publish(`mitra:${n.mitra_id}:requests`, {
|
await notifyMitra(n.mitra_id, {
|
||||||
type: 'chat_request_closed',
|
type: WsMessage.CHAT_REQUEST_CLOSED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -173,7 +210,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
|||||||
export const declinePairingRequest = async (sessionId, mitraId) => {
|
export const declinePairingRequest = async (sessionId, mitraId) => {
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE chat_request_notifications
|
UPDATE chat_request_notifications
|
||||||
SET response = 'declined', responded_at = NOW()
|
SET response = ${NotificationResponse.DECLINED}, responded_at = NOW()
|
||||||
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} AND response IS NULL
|
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} AND response IS NULL
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
@@ -181,9 +218,9 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
|
|||||||
export const cancelPairingRequest = async (sessionId, customerId) => {
|
export const cancelPairingRequest = async (sessionId, customerId) => {
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
UPDATE chat_sessions
|
UPDATE chat_sessions
|
||||||
SET status = 'cancelled'
|
SET status = ${SessionStatus.CANCELLED}
|
||||||
WHERE id = ${sessionId} AND customer_id = ${customerId}
|
WHERE id = ${sessionId} AND customer_id = ${customerId}
|
||||||
AND status IN ('searching', 'pending_acceptance')
|
AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})
|
||||||
RETURNING id, status
|
RETURNING id, status
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -203,7 +240,7 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
|
|||||||
// Mark all notifications as ignored
|
// Mark all notifications as ignored
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE chat_request_notifications
|
UPDATE chat_request_notifications
|
||||||
SET response = 'ignored', responded_at = NOW()
|
SET response = ${NotificationResponse.IGNORED}, responded_at = NOW()
|
||||||
WHERE session_id = ${sessionId} AND response IS NULL
|
WHERE session_id = ${sessionId} AND response IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -212,8 +249,8 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
|
|||||||
SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId}
|
SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId}
|
||||||
`
|
`
|
||||||
for (const n of notifications) {
|
for (const n of notifications) {
|
||||||
await publish(`mitra:${n.mitra_id}:requests`, {
|
await notifyMitra(n.mitra_id, {
|
||||||
type: 'chat_request_closed',
|
type: WsMessage.CHAT_REQUEST_CLOSED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -224,8 +261,8 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
|
|||||||
export const expirePairingRequest = async (sessionId) => {
|
export const expirePairingRequest = async (sessionId) => {
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
UPDATE chat_sessions
|
UPDATE chat_sessions
|
||||||
SET status = 'expired'
|
SET status = ${SessionStatus.EXPIRED}
|
||||||
WHERE id = ${sessionId} AND status = 'pending_acceptance'
|
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
|
||||||
RETURNING id, customer_id, status
|
RETURNING id, customer_id, status
|
||||||
`
|
`
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
@@ -235,13 +272,13 @@ export const expirePairingRequest = async (sessionId) => {
|
|||||||
// Mark all pending notifications as ignored
|
// Mark all pending notifications as ignored
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE chat_request_notifications
|
UPDATE chat_request_notifications
|
||||||
SET response = 'ignored', responded_at = NOW()
|
SET response = ${NotificationResponse.IGNORED}, responded_at = NOW()
|
||||||
WHERE session_id = ${sessionId} AND response IS NULL
|
WHERE session_id = ${sessionId} AND response IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
// Notify customer
|
// Notify customer via WebSocket (FCM fallback)
|
||||||
await publish(`session:${sessionId}:status`, {
|
await notifyCustomer(session.customer_id, {
|
||||||
type: 'expired',
|
type: WsMessage.SESSION_EXPIRED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -250,8 +287,8 @@ export const expirePairingRequest = async (sessionId) => {
|
|||||||
SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId}
|
SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId}
|
||||||
`
|
`
|
||||||
for (const n of notifications) {
|
for (const n of notifications) {
|
||||||
await publish(`mitra:${n.mitra_id}:requests`, {
|
await notifyMitra(n.mitra_id, {
|
||||||
type: 'chat_request_closed',
|
type: WsMessage.CHAT_REQUEST_CLOSED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { getDb } from '../db/client.js'
|
|||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
// Mock price tiers (will come from Control Center config later)
|
// Default tiers as fallback
|
||||||
const PRICE_TIERS = [
|
const DEFAULT_TIERS = [
|
||||||
|
{ duration_minutes: 1, price: 5000, label: '1 Menit (Test)' },
|
||||||
{ duration_minutes: 15, price: 30000, label: '15 Menit' },
|
{ duration_minutes: 15, price: 30000, label: '15 Menit' },
|
||||||
{ duration_minutes: 30, price: 60000, label: '30 Menit' },
|
{ duration_minutes: 30, price: 60000, label: '30 Menit' },
|
||||||
{ duration_minutes: 45, price: 100000, label: '45 Menit' },
|
{ duration_minutes: 45, price: 100000, label: '45 Menit' },
|
||||||
@@ -11,10 +12,14 @@ const PRICE_TIERS = [
|
|||||||
{ duration_minutes: 1440, price: 250000, label: '24 Jam' },
|
{ duration_minutes: 1440, price: 250000, label: '24 Jam' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const getPriceTiers = () => PRICE_TIERS
|
export const getPriceTiers = async () => {
|
||||||
|
const [row] = await sql`SELECT value FROM app_config WHERE key = 'price_tiers'`
|
||||||
|
return row?.value?.tiers ?? DEFAULT_TIERS
|
||||||
|
}
|
||||||
|
|
||||||
export const isValidTier = (durationMinutes, price) => {
|
export const isValidTier = async (durationMinutes, price) => {
|
||||||
return PRICE_TIERS.some(
|
const tiers = await getPriceTiers()
|
||||||
|
return tiers.some(
|
||||||
(t) => t.duration_minutes === durationMinutes && t.price === price
|
(t) => t.duration_minutes === durationMinutes && t.price === price
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -41,7 +46,7 @@ export const isCustomerEligibleForFreeTrial = async (customerId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getPricingForCustomer = async (customerId) => {
|
export const getPricingForCustomer = async (customerId) => {
|
||||||
const tiers = getPriceTiers()
|
const tiers = await getPriceTiers()
|
||||||
const freeTrialEligible = await isCustomerEligibleForFreeTrial(customerId)
|
const freeTrialEligible = await isCustomerEligibleForFreeTrial(customerId)
|
||||||
const freeTrial = await getFreeTrial()
|
const freeTrial = await getFreeTrial()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getDb } from '../db/client.js'
|
import { getDb } from '../db/client.js'
|
||||||
import { publish } from '../plugins/valkey.js'
|
import { publish } from '../plugins/valkey.js'
|
||||||
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||||
|
import { UserType, SessionStatus, WsMessage } from '../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
@@ -62,34 +63,101 @@ export const extendSessionTimer = async (sessionId, additionalMinutes) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSessionWarning = (sessionId) => {
|
const onSessionWarning = (sessionId) => {
|
||||||
const data = { type: 'session_timer', remaining_seconds: 60, session_id: sessionId }
|
const data = { type: WsMessage.SESSION_TIMER, remaining_seconds: 60, session_id: sessionId }
|
||||||
sendToSessionParticipant(sessionId, 'customer', data)
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
|
||||||
sendToSessionParticipant(sessionId, 'mitra', data)
|
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grace period timers for auto-completing abandoned sessions
|
||||||
|
const closureGraceTimers = new Map()
|
||||||
|
|
||||||
|
const CLOSURE_GRACE_PERIOD_MS = 5 * 60_000 // 5 minutes
|
||||||
|
|
||||||
const onSessionExpired = async (sessionId) => {
|
const onSessionExpired = async (sessionId) => {
|
||||||
clearSessionTimer(sessionId)
|
clearSessionTimer(sessionId)
|
||||||
|
|
||||||
// Check session is still active
|
// Move session to closing status
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
SELECT id, status FROM chat_sessions WHERE id = ${sessionId} AND status = 'active'
|
UPDATE chat_sessions
|
||||||
|
SET status = ${SessionStatus.CLOSING}
|
||||||
|
WHERE id = ${sessionId} AND status = ${SessionStatus.ACTIVE}
|
||||||
|
RETURNING id, customer_id, mitra_id
|
||||||
`
|
`
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
// Notify both parties
|
// Notify customer — sees extend/close dialog
|
||||||
const data = { type: 'session_expired', session_id: sessionId }
|
const expiredData = { type: WsMessage.SESSION_EXPIRED, session_id: sessionId }
|
||||||
sendToSessionParticipant(sessionId, 'customer', data)
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, expiredData)
|
||||||
sendToSessionParticipant(sessionId, 'mitra', data)
|
|
||||||
|
// Notify mitra — sees expired + closing (waits for customer's decision or goodbye)
|
||||||
|
sendToSessionParticipant(sessionId, UserType.MITRA, expiredData)
|
||||||
|
sendToSessionParticipant(sessionId, UserType.MITRA, {
|
||||||
|
type: WsMessage.SESSION_CLOSING, session_id: sessionId,
|
||||||
|
})
|
||||||
|
|
||||||
// Also publish via Valkey for any listeners
|
// Also publish via Valkey for any listeners
|
||||||
await publish(`session:${sessionId}:status`, data)
|
await publish(`session:${sessionId}:status`, expiredData)
|
||||||
|
|
||||||
|
// Start grace period — auto-complete if closing messages aren't submitted
|
||||||
|
const graceTimerId = setTimeout(async () => {
|
||||||
|
closureGraceTimers.delete(sessionId)
|
||||||
|
try {
|
||||||
|
const [stale] = await sql`
|
||||||
|
SELECT id FROM chat_sessions
|
||||||
|
WHERE id = ${sessionId} AND status = ${SessionStatus.CLOSING}
|
||||||
|
`
|
||||||
|
if (stale) {
|
||||||
|
await sql`
|
||||||
|
UPDATE chat_sessions
|
||||||
|
SET status = ${SessionStatus.COMPLETED}, ended_at = NOW(), ended_by = 'system'
|
||||||
|
WHERE id = ${sessionId} AND status = ${SessionStatus.CLOSING}
|
||||||
|
`
|
||||||
|
const data = { type: WsMessage.SESSION_COMPLETED, session_id: sessionId }
|
||||||
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
|
||||||
|
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
||||||
|
console.log(`Auto-completed abandoned session ${sessionId} after grace period`)
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, CLOSURE_GRACE_PERIOD_MS)
|
||||||
|
closureGraceTimers.set(sessionId, graceTimerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearClosureGraceTimer = (sessionId) => {
|
||||||
|
const timerId = closureGraceTimers.get(sessionId)
|
||||||
|
if (timerId) {
|
||||||
|
clearTimeout(timerId)
|
||||||
|
closureGraceTimers.delete(sessionId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore timers for active sessions on server restart
|
// Restore timers for active sessions on server restart
|
||||||
export const restoreActiveTimers = async () => {
|
export const restoreActiveTimers = async () => {
|
||||||
|
// Expire sessions that already passed their expires_at while server was down
|
||||||
|
const staleSessions = await sql`
|
||||||
|
UPDATE chat_sessions
|
||||||
|
SET status = ${SessionStatus.COMPLETED}, ended_at = expires_at, ended_by = 'system'
|
||||||
|
WHERE status = ${SessionStatus.ACTIVE} AND expires_at IS NOT NULL AND expires_at <= NOW()
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
if (staleSessions.length > 0) {
|
||||||
|
console.log(`Auto-completed ${staleSessions.length} expired session(s)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-complete sessions stuck in 'closing' status (abandoned during grace period)
|
||||||
|
const staleClosing = await sql`
|
||||||
|
UPDATE chat_sessions
|
||||||
|
SET status = ${SessionStatus.COMPLETED}, ended_at = NOW(), ended_by = 'system'
|
||||||
|
WHERE status = ${SessionStatus.CLOSING}
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
if (staleClosing.length > 0) {
|
||||||
|
console.log(`Auto-completed ${staleClosing.length} abandoned closing session(s)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore timers for sessions still within their time window
|
||||||
const activeSessions = await sql`
|
const activeSessions = await sql`
|
||||||
SELECT id, expires_at FROM chat_sessions
|
SELECT id, expires_at FROM chat_sessions
|
||||||
WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at > NOW()
|
WHERE status = ${SessionStatus.ACTIVE} AND expires_at IS NOT NULL AND expires_at > NOW()
|
||||||
`
|
`
|
||||||
for (const session of activeSessions) {
|
for (const session of activeSessions) {
|
||||||
startSessionTimer(session.id, session.expires_at)
|
startSessionTimer(session.id, session.expires_at)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getDb } from '../db/client.js'
|
import { getDb } from '../db/client.js'
|
||||||
import { publish } from '../plugins/valkey.js'
|
import { publish } from '../plugins/valkey.js'
|
||||||
|
import { UserType, SessionStatus, WsMessage } from '../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ export const getActiveSessionByCustomer = async (customerId) => {
|
|||||||
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', 'extending', 'closing')
|
AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}, ${SessionStatus.EXTENDING}, ${SessionStatus.CLOSING})
|
||||||
ORDER BY cs.created_at DESC LIMIT 1
|
ORDER BY cs.created_at DESC LIMIT 1
|
||||||
`
|
`
|
||||||
return session
|
return session
|
||||||
@@ -25,17 +26,20 @@ export const getActiveSessionsByMitra = async (mitraId) => {
|
|||||||
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', 'extending', 'closing')
|
AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}, ${SessionStatus.EXTENDING}, ${SessionStatus.CLOSING})
|
||||||
ORDER BY cs.created_at DESC
|
ORDER BY cs.created_at DESC
|
||||||
`
|
`
|
||||||
return sessions
|
return sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
export const endSession = async (sessionId, endedBy) => {
|
export const endSession = async (sessionId, endedBy, userId) => {
|
||||||
|
// Validate session belongs to this user
|
||||||
|
const ownerCol = endedBy === UserType.CUSTOMER ? 'customer_id' : 'mitra_id'
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
UPDATE chat_sessions
|
UPDATE chat_sessions
|
||||||
SET status = 'completed', ended_at = NOW(), ended_by = ${endedBy}
|
SET status = ${SessionStatus.COMPLETED}, ended_at = NOW(), ended_by = ${endedBy}
|
||||||
WHERE id = ${sessionId} AND status IN ('active', 'pending_payment')
|
WHERE id = ${sessionId} AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})
|
||||||
|
AND ${sql(ownerCol)} = ${userId}
|
||||||
RETURNING id, customer_id, mitra_id, status, ended_at, ended_by
|
RETURNING id, customer_id, mitra_id, status, ended_at, ended_by
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -47,7 +51,7 @@ export const endSession = async (sessionId, endedBy) => {
|
|||||||
|
|
||||||
// Notify both parties
|
// Notify both parties
|
||||||
await publish(`session:${sessionId}:status`, {
|
await publish(`session:${sessionId}:status`, {
|
||||||
type: 'session_ended',
|
type: WsMessage.SESSION_ENDED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
ended_by: endedBy,
|
ended_by: endedBy,
|
||||||
})
|
})
|
||||||
@@ -59,7 +63,7 @@ export const rerouteSession = async (sessionId, newMitraId) => {
|
|||||||
// Get current session
|
// Get current session
|
||||||
const [current] = await sql`
|
const [current] = await sql`
|
||||||
SELECT id, customer_id, mitra_id, status FROM chat_sessions
|
SELECT id, customer_id, mitra_id, status FROM chat_sessions
|
||||||
WHERE id = ${sessionId} AND status IN ('active', 'pending_payment')
|
WHERE id = ${sessionId} AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})
|
||||||
`
|
`
|
||||||
|
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -94,7 +98,7 @@ export const rerouteSession = async (sessionId, newMitraId) => {
|
|||||||
|
|
||||||
// Notify customer about reroute
|
// Notify customer about reroute
|
||||||
await publish(`session:${sessionId}:status`, {
|
await publish(`session:${sessionId}:status`, {
|
||||||
type: 'rerouted',
|
type: WsMessage.REROUTED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
mitra_display_name: newMitra.display_name,
|
mitra_display_name: newMitra.display_name,
|
||||||
})
|
})
|
||||||
@@ -102,14 +106,14 @@ export const rerouteSession = async (sessionId, newMitraId) => {
|
|||||||
// Notify old mitra session removed
|
// Notify old mitra session removed
|
||||||
if (oldMitraId) {
|
if (oldMitraId) {
|
||||||
await publish(`mitra:${oldMitraId}:requests`, {
|
await publish(`mitra:${oldMitraId}:requests`, {
|
||||||
type: 'session_rerouted',
|
type: WsMessage.SESSION_REROUTED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify new mitra about new session
|
// Notify new mitra about new session
|
||||||
await publish(`mitra:${newMitraId}:requests`, {
|
await publish(`mitra:${newMitraId}:requests`, {
|
||||||
type: 'session_assigned',
|
type: WsMessage.SESSION_ASSIGNED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -157,17 +161,17 @@ export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } =
|
|||||||
SELECT cs.id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at,
|
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,
|
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
|
||||||
m.display_name AS mitra_display_name,
|
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 = ${UserType.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
|
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message
|
||||||
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 = 'completed'
|
AND cs.status = ${SessionStatus.COMPLETED}
|
||||||
ORDER BY cs.ended_at DESC
|
ORDER BY cs.ended_at DESC
|
||||||
LIMIT ${limit} OFFSET ${offset}
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
`
|
`
|
||||||
const [{ count }] = await sql`
|
const [{ count }] = await sql`
|
||||||
SELECT COUNT(*) FROM chat_sessions WHERE customer_id = ${customerId} AND status = 'completed'
|
SELECT COUNT(*) FROM chat_sessions WHERE customer_id = ${customerId} AND status = ${SessionStatus.COMPLETED}
|
||||||
`
|
`
|
||||||
return { items, total: Number(count), page, limit }
|
return { items, total: Number(count), page, limit }
|
||||||
}
|
}
|
||||||
@@ -178,17 +182,17 @@ export const getMitraHistory = async (mitraId, { page = 1, limit = 20 } = {}) =>
|
|||||||
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at,
|
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,
|
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
|
||||||
c.display_name AS customer_display_name,
|
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 = ${UserType.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
|
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message
|
||||||
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 = 'completed'
|
AND cs.status = ${SessionStatus.COMPLETED}
|
||||||
ORDER BY cs.ended_at DESC
|
ORDER BY cs.ended_at DESC
|
||||||
LIMIT ${limit} OFFSET ${offset}
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
`
|
`
|
||||||
const [{ count }] = await sql`
|
const [{ count }] = await sql`
|
||||||
SELECT COUNT(*) FROM chat_sessions WHERE mitra_id = ${mitraId} AND status = 'completed'
|
SELECT COUNT(*) FROM chat_sessions WHERE mitra_id = ${mitraId} AND status = ${SessionStatus.COMPLETED}
|
||||||
`
|
`
|
||||||
return { items, total: Number(count), page, limit }
|
return { items, total: Number(count), page, limit }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ android {
|
|||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,10 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,4 @@ class ApiClient {
|
|||||||
final response = await _dio.get(path, queryParameters: queryParameters);
|
final response = await _dio.get(path, queryParameters: queryParameters);
|
||||||
return response.data as Map<String, dynamic>;
|
return response.data as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Response> getStream(String path) async {
|
|
||||||
return _dio.get(path, options: Options(responseType: ResponseType.stream));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
final _auth = FirebaseAuth.instance;
|
final _auth = FirebaseAuth.instance;
|
||||||
String? _pendingVerificationId;
|
String? _pendingVerificationId;
|
||||||
|
|
||||||
AuthBloc({required this.apiClient}) : super(AuthInitial()) {
|
AuthBloc({required this.apiClient}) : super(AuthLoading()) {
|
||||||
on<AppStarted>(_onAppStarted);
|
on<AppStarted>(_onAppStarted);
|
||||||
on<AnonymousLoginRequested>(_onAnonymousLogin);
|
on<AnonymousLoginRequested>(_onAnonymousLogin);
|
||||||
on<GoogleLoginRequested>(_onGoogleLogin);
|
on<GoogleLoginRequested>(_onGoogleLogin);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:firebase_auth/firebase_auth.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
|
import '../constants.dart';
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
abstract class ChatEvent extends Equatable {
|
abstract class ChatEvent extends Equatable {
|
||||||
@@ -125,8 +126,8 @@ class ChatMessage {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.senderType,
|
required this.senderType,
|
||||||
required this.content,
|
required this.content,
|
||||||
this.type = 'text',
|
this.type = MessageType.text,
|
||||||
this.status = 'sent',
|
this.status = MessageStatus.sent,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,6 +165,19 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
emit(ChatConnecting());
|
emit(ChatConnecting());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Check session status before connecting
|
||||||
|
final sessionInfo = await apiClient.get('/api/shared/chat/${event.sessionId}/info');
|
||||||
|
final sessionData = sessionInfo['data'] as Map<String, dynamic>?;
|
||||||
|
final sessionStatus = sessionData?['status'] as String?;
|
||||||
|
if (sessionStatus == SessionStatus.completed ||
|
||||||
|
sessionStatus == SessionStatus.cancelled ||
|
||||||
|
sessionStatus == SessionStatus.expired) {
|
||||||
|
emit(ChatError('Sesi sudah berakhir.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isClosing = sessionStatus == SessionStatus.closing;
|
||||||
|
|
||||||
// Load existing messages from API
|
// Load existing messages from API
|
||||||
final response = await apiClient.get(
|
final response = await apiClient.get(
|
||||||
'/api/shared/chat/${event.sessionId}/messages',
|
'/api/shared/chat/${event.sessionId}/messages',
|
||||||
@@ -173,8 +187,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
id: m['id'] as String,
|
id: m['id'] as String,
|
||||||
senderType: m['sender_type'] as String,
|
senderType: m['sender_type'] as String,
|
||||||
content: m['content'] as String,
|
content: m['content'] as String,
|
||||||
type: m['type'] as String? ?? 'text',
|
type: m['type'] as String? ?? MessageType.text,
|
||||||
status: m['status'] as String? ?? 'sent',
|
status: m['status'] as String? ?? MessageStatus.sent,
|
||||||
createdAt: DateTime.parse(m['created_at'] as String),
|
createdAt: DateTime.parse(m['created_at'] as String),
|
||||||
)).toList();
|
)).toList();
|
||||||
|
|
||||||
@@ -197,12 +211,15 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
|
|
||||||
// Send auth message
|
// Send auth message
|
||||||
_channel!.sink.add(jsonEncode({
|
_channel!.sink.add(jsonEncode({
|
||||||
'type': 'auth',
|
'type': WsMessage.auth,
|
||||||
'token': token,
|
'token': token,
|
||||||
'session_id': event.sessionId,
|
'session_id': event.sessionId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
emit(ChatConnected(messages: messages));
|
emit(ChatConnected(
|
||||||
|
messages: messages,
|
||||||
|
sessionClosing: isClosing,
|
||||||
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(ChatError('Gagal terhubung ke chat.'));
|
emit(ChatError('Gagal terhubung ke chat.'));
|
||||||
}
|
}
|
||||||
@@ -221,7 +238,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
final msg = ChatMessage(
|
final msg = ChatMessage(
|
||||||
id: tempId,
|
id: tempId,
|
||||||
senderType: 'customer',
|
senderType: UserType.customer,
|
||||||
content: event.content,
|
content: event.content,
|
||||||
status: 'sending',
|
status: 'sending',
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
@@ -230,7 +247,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
emit(current.copyWith(messages: [...current.messages, msg]));
|
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||||
|
|
||||||
_channel!.sink.add(jsonEncode({
|
_channel!.sink.add(jsonEncode({
|
||||||
'type': 'message',
|
'type': WsMessage.message,
|
||||||
'content': event.content,
|
'content': event.content,
|
||||||
'_temp_id': tempId,
|
'_temp_id': tempId,
|
||||||
}));
|
}));
|
||||||
@@ -238,13 +255,13 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
|
|
||||||
void _onSendTyping(SendTyping event, Emitter<ChatState> emit) {
|
void _onSendTyping(SendTyping event, Emitter<ChatState> emit) {
|
||||||
if (_channel == null) return;
|
if (_channel == null) return;
|
||||||
_channel!.sink.add(jsonEncode({'type': 'typing'}));
|
_channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<ChatState> emit) {
|
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<ChatState> emit) {
|
||||||
if (_channel == null) return;
|
if (_channel == null) return;
|
||||||
_channel!.sink.add(jsonEncode({
|
_channel!.sink.add(jsonEncode({
|
||||||
'type': 'delivered',
|
'type': WsMessage.delivered,
|
||||||
'message_ids': event.messageIds,
|
'message_ids': event.messageIds,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -252,7 +269,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
void _onMarkRead(MarkMessagesRead event, Emitter<ChatState> emit) {
|
void _onMarkRead(MarkMessagesRead event, Emitter<ChatState> emit) {
|
||||||
if (_channel == null) return;
|
if (_channel == null) return;
|
||||||
_channel!.sink.add(jsonEncode({
|
_channel!.sink.add(jsonEncode({
|
||||||
'type': 'read',
|
'type': WsMessage.read,
|
||||||
'message_ids': event.messageIds,
|
'message_ids': event.messageIds,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -264,17 +281,17 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
final type = data['type'] as String?;
|
final type = data['type'] as String?;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'auth_ok':
|
case WsMessage.authOk:
|
||||||
// Already connected
|
// Already connected
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'message':
|
case WsMessage.message:
|
||||||
final msg = ChatMessage(
|
final msg = ChatMessage(
|
||||||
id: data['message_id'] as String,
|
id: data['message_id'] as String,
|
||||||
senderType: data['sender_type'] as String,
|
senderType: data['sender_type'] as String,
|
||||||
content: data['content'] as String,
|
content: data['content'] as String,
|
||||||
type: data['message_type'] as String? ?? 'text',
|
type: data['message_type'] as String? ?? MessageType.text,
|
||||||
status: 'sent',
|
status: MessageStatus.sent,
|
||||||
createdAt: DateTime.parse(data['created_at'] as String),
|
createdAt: DateTime.parse(data['created_at'] as String),
|
||||||
);
|
);
|
||||||
emit(current.copyWith(messages: [...current.messages, msg]));
|
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||||
@@ -282,7 +299,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
add(MarkMessagesDelivered([msg.id]));
|
add(MarkMessagesDelivered([msg.id]));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'message_ack':
|
case WsMessage.messageAck:
|
||||||
final messageId = data['message_id'] as String;
|
final messageId = data['message_id'] as String;
|
||||||
final status = data['status'] as String;
|
final status = data['status'] as String;
|
||||||
final updatedMessages = current.messages.map((m) {
|
final updatedMessages = current.messages.map((m) {
|
||||||
@@ -292,7 +309,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
return m;
|
return m;
|
||||||
}).toList();
|
}).toList();
|
||||||
// Replace temp ID with real ID
|
// Replace temp ID with real ID
|
||||||
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == 'customer');
|
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.customer);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
final old = updatedMessages[idx];
|
final old = updatedMessages[idx];
|
||||||
updatedMessages[idx] = ChatMessage(
|
updatedMessages[idx] = ChatMessage(
|
||||||
@@ -307,7 +324,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
emit(current.copyWith(messages: updatedMessages));
|
emit(current.copyWith(messages: updatedMessages));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'message_status':
|
case WsMessage.messageStatus:
|
||||||
final messageIds = (data['message_ids'] as List<dynamic>).cast<String>();
|
final messageIds = (data['message_ids'] as List<dynamic>).cast<String>();
|
||||||
final status = data['status'] as String;
|
final status = data['status'] as String;
|
||||||
final updatedMessages = current.messages.map((m) {
|
final updatedMessages = current.messages.map((m) {
|
||||||
@@ -319,7 +336,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
emit(current.copyWith(messages: updatedMessages));
|
emit(current.copyWith(messages: updatedMessages));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'typing':
|
case WsMessage.typing:
|
||||||
emit(current.copyWith(isOtherTyping: true));
|
emit(current.copyWith(isOtherTyping: true));
|
||||||
_typingTimer?.cancel();
|
_typingTimer?.cancel();
|
||||||
_typingTimer = Timer(const Duration(seconds: 3), () {
|
_typingTimer = Timer(const Duration(seconds: 3), () {
|
||||||
@@ -329,36 +346,41 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session_timer':
|
case WsMessage.sessionTimer:
|
||||||
final remaining = data['remaining_seconds'] as int?;
|
final remaining = data['remaining_seconds'] as int?;
|
||||||
emit(current.copyWith(remainingSeconds: remaining));
|
emit(current.copyWith(remainingSeconds: remaining));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session_expired':
|
case WsMessage.sessionExpired:
|
||||||
emit(current.copyWith(sessionExpired: true));
|
emit(current.copyWith(sessionExpired: true));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session_paused':
|
case WsMessage.sessionPaused:
|
||||||
emit(current.copyWith(sessionPaused: true));
|
emit(current.copyWith(sessionPaused: true));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session_resumed':
|
case WsMessage.sessionResumed:
|
||||||
emit(current.copyWith(sessionPaused: false, sessionExpired: false));
|
emit(current.copyWith(sessionPaused: false, sessionExpired: false, sessionClosing: false));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session_closing':
|
case WsMessage.sessionClosing:
|
||||||
emit(current.copyWith(sessionClosing: true));
|
emit(current.copyWith(sessionClosing: true));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'extension_response':
|
case WsMessage.extensionResponse:
|
||||||
emit(current.copyWith(extensionResponse: data));
|
final accepted = data['accepted'] as bool? ?? false;
|
||||||
|
emit(current.copyWith(
|
||||||
|
extensionResponse: data,
|
||||||
|
sessionPaused: accepted ? false : current.sessionPaused,
|
||||||
|
sessionExpired: accepted ? false : current.sessionExpired,
|
||||||
|
));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session_completed':
|
case WsMessage.sessionCompleted:
|
||||||
_cleanup();
|
_cleanup();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'error':
|
case WsMessage.error:
|
||||||
// Keep connected but show error
|
// Keep connected but show error
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class RequestExtension extends SessionClosureEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class DeclineExtension extends SessionClosureEvent {}
|
class DeclineExtension extends SessionClosureEvent {}
|
||||||
|
class ResetClosure extends SessionClosureEvent {}
|
||||||
|
|
||||||
class SubmitGoodbye extends SessionClosureEvent {
|
class SubmitGoodbye extends SessionClosureEvent {
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
@@ -56,6 +57,7 @@ class SessionClosureBloc extends Bloc<SessionClosureEvent, SessionClosureState>
|
|||||||
SessionClosureBloc({required this.apiClient}) : super(ClosureInitial()) {
|
SessionClosureBloc({required this.apiClient}) : super(ClosureInitial()) {
|
||||||
on<RequestExtension>(_onRequestExtension);
|
on<RequestExtension>(_onRequestExtension);
|
||||||
on<DeclineExtension>(_onDeclineExtension);
|
on<DeclineExtension>(_onDeclineExtension);
|
||||||
|
on<ResetClosure>(_onReset);
|
||||||
on<SubmitGoodbye>(_onSubmitGoodbye);
|
on<SubmitGoodbye>(_onSubmitGoodbye);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +78,10 @@ class SessionClosureBloc extends Bloc<SessionClosureEvent, SessionClosureState>
|
|||||||
emit(ClosureShowGoodbye());
|
emit(ClosureShowGoodbye());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onReset(ResetClosure event, Emitter<SessionClosureState> emit) {
|
||||||
|
emit(ClosureInitial());
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onSubmitGoodbye(SubmitGoodbye event, Emitter<SessionClosureState> emit) async {
|
Future<void> _onSubmitGoodbye(SubmitGoodbye event, Emitter<SessionClosureState> emit) async {
|
||||||
emit(ClosureSubmitting());
|
emit(ClosureSubmitting());
|
||||||
try {
|
try {
|
||||||
|
|||||||
83
client_app/lib/core/constants.dart
Normal file
83
client_app/lib/core/constants.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/// User types
|
||||||
|
class UserType {
|
||||||
|
static const customer = 'customer';
|
||||||
|
static const mitra = 'mitra';
|
||||||
|
UserType._();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chat session statuses
|
||||||
|
class SessionStatus {
|
||||||
|
static const searching = 'searching';
|
||||||
|
static const pendingAcceptance = 'pending_acceptance';
|
||||||
|
static const pendingPayment = 'pending_payment';
|
||||||
|
static const active = 'active';
|
||||||
|
static const extending = 'extending';
|
||||||
|
static const closing = 'closing';
|
||||||
|
static const completed = 'completed';
|
||||||
|
static const cancelled = 'cancelled';
|
||||||
|
static const expired = 'expired';
|
||||||
|
SessionStatus._();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chat message statuses
|
||||||
|
class MessageStatus {
|
||||||
|
static const sent = 'sent';
|
||||||
|
static const delivered = 'delivered';
|
||||||
|
static const read = 'read';
|
||||||
|
MessageStatus._();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chat message types
|
||||||
|
class MessageType {
|
||||||
|
static const text = 'text';
|
||||||
|
MessageType._();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session extension statuses
|
||||||
|
class ExtensionStatus {
|
||||||
|
static const pending = 'pending';
|
||||||
|
static const accepted = 'accepted';
|
||||||
|
static const rejected = 'rejected';
|
||||||
|
static const timeout = 'timeout';
|
||||||
|
ExtensionStatus._();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebSocket message types
|
||||||
|
class WsMessage {
|
||||||
|
// Auth
|
||||||
|
static const auth = 'auth';
|
||||||
|
static const authOk = 'auth_ok';
|
||||||
|
static const error = 'error';
|
||||||
|
|
||||||
|
// Chat
|
||||||
|
static const message = 'message';
|
||||||
|
static const messageAck = 'message_ack';
|
||||||
|
static const messageStatus = 'message_status';
|
||||||
|
static const typing = 'typing';
|
||||||
|
|
||||||
|
// Pairing
|
||||||
|
static const chatRequest = 'chat_request';
|
||||||
|
static const chatRequestClosed = 'chat_request_closed';
|
||||||
|
static const paired = 'paired';
|
||||||
|
|
||||||
|
// Session lifecycle
|
||||||
|
static const sessionTimer = 'session_timer';
|
||||||
|
static const sessionExpired = 'session_expired';
|
||||||
|
static const sessionClosing = 'session_closing';
|
||||||
|
static const sessionCompleted = 'session_completed';
|
||||||
|
static const sessionPaused = 'session_paused';
|
||||||
|
static const sessionResumed = 'session_resumed';
|
||||||
|
|
||||||
|
// Extension
|
||||||
|
static const extensionRequest = 'extension_request';
|
||||||
|
static const extensionResponse = 'extension_response';
|
||||||
|
|
||||||
|
// Delivery
|
||||||
|
static const delivered = 'delivered';
|
||||||
|
static const read = 'read';
|
||||||
|
|
||||||
|
// Early end
|
||||||
|
static const earlyEnd = 'early_end';
|
||||||
|
|
||||||
|
WsMessage._();
|
||||||
|
}
|
||||||
95
client_app/lib/core/notifications/notification_service.dart
Normal file
95
client_app/lib/core/notifications/notification_service.dart
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// Handles FCM foreground/background notifications and local notification display.
|
||||||
|
class NotificationService {
|
||||||
|
static final _localNotifications = FlutterLocalNotificationsPlugin();
|
||||||
|
static GoRouter? _router;
|
||||||
|
|
||||||
|
static const _channel = AndroidNotificationChannel(
|
||||||
|
'chat_messages',
|
||||||
|
'Chat Messages',
|
||||||
|
description: 'Notifications for incoming chat messages',
|
||||||
|
importance: Importance.high,
|
||||||
|
);
|
||||||
|
|
||||||
|
static Future<void> initialize(GoRouter router) async {
|
||||||
|
_router = router;
|
||||||
|
|
||||||
|
// Create Android notification channel
|
||||||
|
await _localNotifications
|
||||||
|
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||||
|
?.createNotificationChannel(_channel);
|
||||||
|
|
||||||
|
// Initialize local notifications
|
||||||
|
await _localNotifications.initialize(
|
||||||
|
settings: const InitializationSettings(
|
||||||
|
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||||
|
iOS: DarwinInitializationSettings(),
|
||||||
|
),
|
||||||
|
onDidReceiveNotificationResponse: _onNotificationTap,
|
||||||
|
);
|
||||||
|
|
||||||
|
// FCM foreground messages → show local notification
|
||||||
|
FirebaseMessaging.onMessage.listen(_onForegroundMessage);
|
||||||
|
|
||||||
|
// FCM notification tap (app was in background)
|
||||||
|
FirebaseMessaging.onMessageOpenedApp.listen(_onMessageOpenedApp);
|
||||||
|
|
||||||
|
// Check if app was opened from a terminated state via notification
|
||||||
|
final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
|
||||||
|
if (initialMessage != null) {
|
||||||
|
_navigateFromMessage(initialMessage.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _onForegroundMessage(RemoteMessage message) {
|
||||||
|
final notification = message.notification;
|
||||||
|
if (notification == null) return;
|
||||||
|
|
||||||
|
_localNotifications.show(
|
||||||
|
id: notification.hashCode,
|
||||||
|
title: notification.title,
|
||||||
|
body: notification.body,
|
||||||
|
notificationDetails: NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
_channel.id,
|
||||||
|
_channel.name,
|
||||||
|
channelDescription: _channel.description,
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
),
|
||||||
|
iOS: const DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
payload: jsonEncode(message.data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _onNotificationTap(NotificationResponse response) {
|
||||||
|
if (response.payload == null) return;
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(response.payload!) as Map<String, dynamic>;
|
||||||
|
_navigateFromMessage(data);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _onMessageOpenedApp(RemoteMessage message) {
|
||||||
|
_navigateFromMessage(message.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _navigateFromMessage(Map<String, dynamic> data) {
|
||||||
|
final sessionId = data['session_id'] as String?;
|
||||||
|
if (sessionId == null || _router == null) return;
|
||||||
|
|
||||||
|
final type = data['type'] as String?;
|
||||||
|
if (type == 'chat_message' || type == 'chat_request') {
|
||||||
|
_router!.push('/chat/session/$sessionId');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
|
import '../constants.dart';
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
abstract class PairingEvent extends Equatable {
|
abstract class PairingEvent extends Equatable {
|
||||||
@@ -32,6 +35,7 @@ class _PairingStatusUpdate extends PairingEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _PairingTimeout extends PairingEvent {}
|
class _PairingTimeout extends PairingEvent {}
|
||||||
|
class _ConnectionError extends PairingEvent {}
|
||||||
|
|
||||||
// States
|
// States
|
||||||
abstract class PairingState extends Equatable {
|
abstract class PairingState extends Equatable {
|
||||||
@@ -77,7 +81,8 @@ class PairingError extends PairingState {
|
|||||||
class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
Timer? _timeoutTimer;
|
Timer? _timeoutTimer;
|
||||||
StreamSubscription? _sseSubscription;
|
WebSocketChannel? _channel;
|
||||||
|
StreamSubscription? _wsSubscription;
|
||||||
|
|
||||||
PairingBloc({required this.apiClient}) : super(PairingInitial()) {
|
PairingBloc({required this.apiClient}) : super(PairingInitial()) {
|
||||||
on<RequestPairing>(_onRequestPairing);
|
on<RequestPairing>(_onRequestPairing);
|
||||||
@@ -85,6 +90,7 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
|||||||
on<CancelPairing>(_onCancelPairing);
|
on<CancelPairing>(_onCancelPairing);
|
||||||
on<_PairingStatusUpdate>(_onStatusUpdate);
|
on<_PairingStatusUpdate>(_onStatusUpdate);
|
||||||
on<_PairingTimeout>(_onTimeout);
|
on<_PairingTimeout>(_onTimeout);
|
||||||
|
on<_ConnectionError>(_onConnectionError);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onRequestPairing(RequestPairing event, Emitter<PairingState> emit) async {
|
Future<void> _onRequestPairing(RequestPairing event, Emitter<PairingState> emit) async {
|
||||||
@@ -107,6 +113,9 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
|||||||
emit(PairingInitial());
|
emit(PairingInitial());
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// Connect to WebSocket first to listen for pairing status
|
||||||
|
await _connectWebSocket();
|
||||||
|
|
||||||
final response = await apiClient.post('/api/client/chat/request', data: body);
|
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;
|
||||||
@@ -116,9 +125,8 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
|||||||
_timeoutTimer = Timer(const Duration(seconds: 60), () {
|
_timeoutTimer = Timer(const Duration(seconds: 60), () {
|
||||||
add(_PairingTimeout());
|
add(_PairingTimeout());
|
||||||
});
|
});
|
||||||
|
|
||||||
_listenToSSE(sessionId);
|
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
|
_cleanup();
|
||||||
final code = e.response?.data?['error']?['code'];
|
final code = e.response?.data?['error']?['code'];
|
||||||
if (code == 'NO_MITRA_AVAILABLE') {
|
if (code == 'NO_MITRA_AVAILABLE') {
|
||||||
emit(PairingNoBestie());
|
emit(PairingNoBestie());
|
||||||
@@ -132,26 +140,45 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _listenToSSE(String sessionId) {
|
Future<void> _connectWebSocket() async {
|
||||||
apiClient.getStream('/api/client/chat/request/$sessionId/status').then((response) {
|
_closeWebSocket();
|
||||||
final stream = response.data.stream as Stream<List<int>>;
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
_sseSubscription = stream
|
if (user == null) return;
|
||||||
.transform(utf8.decoder)
|
|
||||||
.transform(const LineSplitter())
|
final token = await user.getIdToken();
|
||||||
.where((line) => line.startsWith('data: '))
|
final wsUrl = ApiClient.baseUrl
|
||||||
.map((line) => jsonDecode(line.substring(6)) as Map<String, dynamic>)
|
.replaceFirst('https://', 'wss://')
|
||||||
.listen(
|
.replaceFirst('http://', 'ws://');
|
||||||
(data) => add(_PairingStatusUpdate(data)),
|
|
||||||
onError: (_) {},
|
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
|
||||||
);
|
|
||||||
}).catchError((_) {});
|
_wsSubscription = _channel!.stream.listen(
|
||||||
|
(raw) {
|
||||||
|
final data = jsonDecode(raw as String) as Map<String, dynamic>;
|
||||||
|
if (data['type'] == WsMessage.authOk) return;
|
||||||
|
add(_PairingStatusUpdate(data));
|
||||||
|
},
|
||||||
|
onError: (_) => add(_ConnectionError()),
|
||||||
|
onDone: () => add(_ConnectionError()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Authenticate without session_id — just for receiving pairing status
|
||||||
|
_channel!.sink.add(jsonEncode({
|
||||||
|
'type': WsMessage.auth,
|
||||||
|
'token': token,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onConnectionError(_ConnectionError event, Emitter<PairingState> emit) async {
|
||||||
|
// WebSocket disconnected during pairing — stay in current state,
|
||||||
|
// FCM will still deliver notifications
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onStatusUpdate(_PairingStatusUpdate event, Emitter<PairingState> emit) async {
|
Future<void> _onStatusUpdate(_PairingStatusUpdate event, Emitter<PairingState> emit) async {
|
||||||
final data = event.data;
|
final data = event.data;
|
||||||
final type = data['type'] as String?;
|
final type = data['type'] as String?;
|
||||||
|
|
||||||
if (type == 'paired') {
|
if (type == WsMessage.paired) {
|
||||||
_cleanup();
|
_cleanup();
|
||||||
final mitraName = data['mitra_display_name'] as String? ?? 'Bestie';
|
final mitraName = data['mitra_display_name'] as String? ?? 'Bestie';
|
||||||
final sessionId = data['session_id'] as String;
|
final sessionId = data['session_id'] as String;
|
||||||
@@ -160,7 +187,7 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
|||||||
// Brief delay then transition to active
|
// Brief delay then transition to active
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
emit(PairingActive(sessionId: sessionId, mitraName: mitraName));
|
emit(PairingActive(sessionId: sessionId, mitraName: mitraName));
|
||||||
} else if (type == 'expired') {
|
} else if (type == SessionStatus.expired) {
|
||||||
_cleanup();
|
_cleanup();
|
||||||
emit(PairingNoBestie());
|
emit(PairingNoBestie());
|
||||||
}
|
}
|
||||||
@@ -182,11 +209,17 @@ class PairingBloc extends Bloc<PairingEvent, PairingState> {
|
|||||||
emit(PairingNoBestie());
|
emit(PairingNoBestie());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _closeWebSocket() {
|
||||||
|
_wsSubscription?.cancel();
|
||||||
|
_wsSubscription = null;
|
||||||
|
_channel?.sink.close();
|
||||||
|
_channel = null;
|
||||||
|
}
|
||||||
|
|
||||||
void _cleanup() {
|
void _cleanup() {
|
||||||
_timeoutTimer?.cancel();
|
_timeoutTimer?.cancel();
|
||||||
_timeoutTimer = null;
|
_timeoutTimer = null;
|
||||||
_sseSubscription?.cancel();
|
_closeWebSocket();
|
||||||
_sseSubscription = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/chat/chat_bloc.dart';
|
import '../../../core/chat/chat_bloc.dart';
|
||||||
import '../../../core/chat/session_closure_bloc.dart';
|
import '../../../core/chat/session_closure_bloc.dart';
|
||||||
|
import '../../../core/constants.dart';
|
||||||
import '../widgets/pricing_bottom_sheet.dart';
|
import '../widgets/pricing_bottom_sheet.dart';
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
@@ -21,8 +22,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
Timer? _typingThrottle;
|
Timer? _typingThrottle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
context.read<ChatBloc>().add(ConnectChat(widget.sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
context.read<ChatBloc>().add(DisconnectChat());
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_typingThrottle?.cancel();
|
_typingThrottle?.cancel();
|
||||||
@@ -64,19 +72,31 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
if (prev is ChatConnected && curr is ChatConnected) {
|
if (prev is ChatConnected && curr is ChatConnected) {
|
||||||
return prev.sessionExpired != curr.sessionExpired ||
|
return prev.sessionExpired != curr.sessionExpired ||
|
||||||
prev.sessionClosing != curr.sessionClosing ||
|
prev.sessionClosing != curr.sessionClosing ||
|
||||||
|
prev.sessionPaused != curr.sessionPaused ||
|
||||||
prev.messages.length != curr.messages.length;
|
prev.messages.length != curr.messages.length;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is ChatConnected) {
|
if (state is ChatConnected) {
|
||||||
if (state.sessionClosing) {
|
// Only trigger goodbye if closing AND not expired (expired shows extend dialog first)
|
||||||
context.read<SessionClosureBloc>().add(DeclineExtension());
|
if (state.sessionClosing && !state.sessionExpired) {
|
||||||
|
final closureState = context.read<SessionClosureBloc>().state;
|
||||||
|
if (closureState is ClosureInitial) {
|
||||||
|
context.read<SessionClosureBloc>().add(DeclineExtension());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Extension accepted — reset closure bloc to go back to chat
|
||||||
|
if (!state.sessionPaused && !state.sessionExpired && !state.sessionClosing) {
|
||||||
|
final closureState = context.read<SessionClosureBloc>().state;
|
||||||
|
if (closureState is! ClosureInitial) {
|
||||||
|
context.read<SessionClosureBloc>().add(ResetClosure());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
// Auto-mark received messages as read
|
// Auto-mark received messages as read
|
||||||
final unread = state.messages
|
final unread = state.messages
|
||||||
.where((m) => m.senderType == 'mitra' && m.status != 'read')
|
.where((m) => m.senderType == UserType.mitra && m.status != MessageStatus.read)
|
||||||
.map((m) => m.id)
|
.map((m) => m.id)
|
||||||
.toList();
|
.toList();
|
||||||
if (unread.isNotEmpty) {
|
if (unread.isNotEmpty) {
|
||||||
@@ -138,17 +158,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChatBody(BuildContext context, ChatConnected state) {
|
Widget _buildChatBody(BuildContext context, ChatConnected state) {
|
||||||
// Show session expired dialog
|
// Show goodbye input (takes priority — user already decided to close)
|
||||||
if (state.sessionExpired) {
|
|
||||||
return _buildExpiredView(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show goodbye input
|
|
||||||
final closureState = context.watch<SessionClosureBloc>().state;
|
final closureState = context.watch<SessionClosureBloc>().state;
|
||||||
if (closureState is ClosureShowGoodbye || closureState is ClosureSubmitting) {
|
if (closureState is ClosureShowGoodbye || closureState is ClosureSubmitting) {
|
||||||
return _buildGoodbyeView(context, closureState);
|
return _buildGoodbyeView(context, closureState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show session expired dialog (extend or close?)
|
||||||
|
if (state.sessionExpired) {
|
||||||
|
return _buildExpiredView(context);
|
||||||
|
}
|
||||||
|
|
||||||
if (state.sessionPaused) {
|
if (state.sessionPaused) {
|
||||||
return _buildPausedView();
|
return _buildPausedView();
|
||||||
}
|
}
|
||||||
@@ -162,7 +182,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
itemCount: state.messages.length,
|
itemCount: state.messages.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final msg = state.messages[index];
|
final msg = state.messages[index];
|
||||||
final isMe = msg.senderType == 'customer';
|
final isMe = msg.senderType == UserType.customer;
|
||||||
return _buildMessageBubble(msg, isMe);
|
return _buildMessageBubble(msg, isMe);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -219,11 +239,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'sending':
|
case 'sending':
|
||||||
return const Icon(Icons.access_time, size: 14, color: Colors.grey);
|
return const Icon(Icons.access_time, size: 14, color: Colors.grey);
|
||||||
case 'sent':
|
case MessageStatus.sent:
|
||||||
return const Icon(Icons.check, size: 14, color: Colors.grey);
|
return const Icon(Icons.check, size: 14, color: Colors.grey);
|
||||||
case 'delivered':
|
case MessageStatus.delivered:
|
||||||
return const Icon(Icons.done_all, size: 14, color: Colors.grey);
|
return const Icon(Icons.done_all, size: 14, color: Colors.grey);
|
||||||
case 'read':
|
case MessageStatus.read:
|
||||||
return const Icon(Icons.done_all, size: 14, color: Colors.blue);
|
return const Icon(Icons.done_all, size: 14, color: Colors.blue);
|
||||||
default:
|
default:
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
@@ -264,25 +284,48 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Column(
|
child: TweenAnimationBuilder<int>(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
tween: IntTween(begin: 300, end: 0),
|
||||||
children: [
|
duration: const Duration(seconds: 300),
|
||||||
const Icon(Icons.timer_off, size: 64, color: Colors.orange),
|
builder: (context, remaining, _) {
|
||||||
const SizedBox(height: 16),
|
if (remaining <= 0) {
|
||||||
const Text('Waktu sesi habis', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
// Auto-decline when countdown reaches 0
|
||||||
const SizedBox(height: 8),
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
const Text('Apakah kamu ingin memperpanjang sesi?', textAlign: TextAlign.center),
|
context.read<SessionClosureBloc>().add(DeclineExtension());
|
||||||
const SizedBox(height: 24),
|
});
|
||||||
ElevatedButton(
|
}
|
||||||
onPressed: () => PricingBottomSheet.show(context),
|
final minutes = remaining ~/ 60;
|
||||||
child: const Text('Perpanjang Sesi'),
|
final seconds = remaining % 60;
|
||||||
),
|
return Column(
|
||||||
const SizedBox(height: 12),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
TextButton(
|
children: [
|
||||||
onPressed: () => context.read<SessionClosureBloc>().add(DeclineExtension()),
|
const Icon(Icons.timer_off, size: 64, color: Colors.orange),
|
||||||
child: const Text('Tidak, akhiri sesi'),
|
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: 12),
|
||||||
|
Text(
|
||||||
|
'$minutes:${seconds.toString().padLeft(2, '0')}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: remaining < 60 ? Colors.red : Colors.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => PricingBottomSheet.showForExtension(context, sessionId: widget.sessionId),
|
||||||
|
child: const Text('Perpanjang Sesi'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.read<SessionClosureBloc>().add(DeclineExtension()),
|
||||||
|
child: const Text('Tidak, akhiri sesi'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -290,13 +333,12 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
Widget _buildGoodbyeView(BuildContext context, SessionClosureState closureState) {
|
Widget _buildGoodbyeView(BuildContext context, SessionClosureState closureState) {
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
return Center(
|
return SingleChildScrollView(
|
||||||
child: Padding(
|
padding: const EdgeInsets.all(32),
|
||||||
padding: const EdgeInsets.all(32),
|
child: Column(
|
||||||
child: Column(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
const SizedBox(height: 48),
|
||||||
children: [
|
const Icon(Icons.waving_hand, size: 64, color: Colors.amber),
|
||||||
const Icon(Icons.waving_hand, size: 64, color: Colors.amber),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -328,7 +370,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../core/api/api_client.dart';
|
import '../../../core/api/api_client.dart';
|
||||||
|
import '../../../core/constants.dart';
|
||||||
|
|
||||||
class ChatTranscriptScreen extends StatefulWidget {
|
class ChatTranscriptScreen extends StatefulWidget {
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
@@ -47,7 +48,7 @@ class _ChatTranscriptScreenState extends State<ChatTranscriptScreen> {
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
..._messages.map((m) {
|
..._messages.map((m) {
|
||||||
final isMe = m['sender_type'] == 'customer';
|
final isMe = m['sender_type'] == UserType.customer;
|
||||||
final time = DateTime.parse(m['created_at'] as String).toLocal();
|
final time = DateTime.parse(m['created_at'] as String).toLocal();
|
||||||
return Align(
|
return Align(
|
||||||
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
@@ -79,7 +80,7 @@ class _ChatTranscriptScreenState extends State<ChatTranscriptScreen> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
..._closures.map((c) => Card(
|
..._closures.map((c) => Card(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
title: Text(c['user_type'] == 'customer' ? 'Kamu' : 'Bestie'),
|
title: Text(c['user_type'] == UserType.customer ? 'Kamu' : 'Bestie'),
|
||||||
subtitle: Text(c['message'] as String),
|
subtitle: Text(c['message'] as String),
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -2,18 +2,45 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../core/api/api_client.dart';
|
import '../../../core/api/api_client.dart';
|
||||||
import '../../../core/chat/chat_opening_bloc.dart';
|
import '../../../core/chat/chat_opening_bloc.dart';
|
||||||
|
import '../../../core/chat/session_closure_bloc.dart';
|
||||||
import '../../../core/pairing/pairing_bloc.dart';
|
import '../../../core/pairing/pairing_bloc.dart';
|
||||||
|
|
||||||
class PricingBottomSheet extends StatelessWidget {
|
class PricingBottomSheet extends StatelessWidget {
|
||||||
const PricingBottomSheet({super.key});
|
/// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session.
|
||||||
|
final String? extensionSessionId;
|
||||||
|
|
||||||
|
const PricingBottomSheet({super.key, this.extensionSessionId});
|
||||||
|
|
||||||
|
/// Show for new pairing (from home screen)
|
||||||
static Future<void> show(BuildContext context) {
|
static Future<void> show(BuildContext context) {
|
||||||
return showModalBottomSheet(
|
return showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (_) => BlocProvider(
|
builder: (_) => BlocProvider(
|
||||||
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
|
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
|
||||||
child: const PricingBottomSheet(),
|
child: MultiBlocProvider(
|
||||||
|
providers: [
|
||||||
|
BlocProvider.value(value: context.read<PairingBloc>()),
|
||||||
|
],
|
||||||
|
child: const PricingBottomSheet(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show for session extension (from chat screen)
|
||||||
|
static Future<void> showForExtension(BuildContext context, {required String sessionId}) {
|
||||||
|
return showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => BlocProvider(
|
||||||
|
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
|
||||||
|
child: MultiBlocProvider(
|
||||||
|
providers: [
|
||||||
|
BlocProvider.value(value: context.read<SessionClosureBloc>()),
|
||||||
|
],
|
||||||
|
child: PricingBottomSheet(extensionSessionId: sessionId),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -30,6 +57,8 @@ class PricingBottomSheet extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isExtension = extensionSessionId != null;
|
||||||
|
|
||||||
return BlocBuilder<ChatOpeningBloc, ChatOpeningState>(
|
return BlocBuilder<ChatOpeningBloc, ChatOpeningState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is PricingLoading || state is PricingInitial) {
|
if (state is PricingLoading || state is PricingInitial) {
|
||||||
@@ -58,13 +87,13 @@ class PricingBottomSheet extends StatelessWidget {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Pilih Durasi Curhat',
|
isExtension ? 'Perpanjang Durasi' : 'Pilih Durasi Curhat',
|
||||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (state.freeTrialEligible) ...[
|
if (!isExtension && state.freeTrialEligible) ...[
|
||||||
Card(
|
Card(
|
||||||
color: Colors.green.shade50,
|
color: Colors.green.shade50,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
@@ -89,11 +118,20 @@ class PricingBottomSheet extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
_startPairing(
|
if (isExtension) {
|
||||||
context,
|
_requestExtension(
|
||||||
durationMinutes: tier.durationMinutes,
|
context,
|
||||||
price: tier.price,
|
sessionId: extensionSessionId!,
|
||||||
);
|
durationMinutes: tier.durationMinutes,
|
||||||
|
price: tier.price,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_startPairing(
|
||||||
|
context,
|
||||||
|
durationMinutes: tier.durationMinutes,
|
||||||
|
price: tier.price,
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
@@ -116,4 +154,12 @@ class PricingBottomSheet extends StatelessWidget {
|
|||||||
isFreeTrial: isFreeTrial,
|
isFreeTrial: isFreeTrial,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _requestExtension(BuildContext context, {required String sessionId, required int durationMinutes, required int price}) {
|
||||||
|
context.read<SessionClosureBloc>().add(RequestExtension(
|
||||||
|
sessionId: sessionId,
|
||||||
|
durationMinutes: durationMinutes,
|
||||||
|
price: price,
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,64 @@ 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/auth/auth_bloc.dart';
|
import '../../core/auth/auth_bloc.dart';
|
||||||
|
import '../../core/api/api_client.dart';
|
||||||
import '../../core/pairing/pairing_bloc.dart';
|
import '../../core/pairing/pairing_bloc.dart';
|
||||||
import '../chat/widgets/pricing_bottom_sheet.dart';
|
import '../chat/widgets/pricing_bottom_sheet.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HomeScreen> createState() => _HomeScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
||||||
|
Map<String, dynamic>? _activeSession;
|
||||||
|
bool _loadingSession = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_checkActiveSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
_checkActiveSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
// Re-check when navigating back to this screen
|
||||||
|
_checkActiveSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkActiveSession() async {
|
||||||
|
try {
|
||||||
|
final apiClient = context.read<ApiClient>();
|
||||||
|
final response = await apiClient.get('/api/client/chat/session/active');
|
||||||
|
final data = response['data'];
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_activeSession = data is Map<String, dynamic> ? data : null;
|
||||||
|
_loadingSession = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) setState(() => _loadingSession = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocListener<PairingBloc, PairingState>(
|
return BlocListener<PairingBloc, PairingState>(
|
||||||
@@ -51,14 +103,28 @@ class HomeScreen extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
|
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 32),
|
||||||
ElevatedButton(
|
if (_loadingSession)
|
||||||
style: ElevatedButton.styleFrom(
|
const CircularProgressIndicator()
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
else if (_activeSession != null)
|
||||||
|
_ActiveSessionCard(
|
||||||
|
session: _activeSession!,
|
||||||
|
onTap: () {
|
||||||
|
final sessionId = _activeSession!['id'] as String;
|
||||||
|
final mitraName = _activeSession!['mitra_display_name'] as String? ?? 'Bestie';
|
||||||
|
context.push('/chat/session/$sessionId', extra: mitraName);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
||||||
|
),
|
||||||
|
onPressed: () => PricingBottomSheet.show(context),
|
||||||
|
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
|
||||||
),
|
),
|
||||||
onPressed: () => PricingBottomSheet.show(context),
|
],
|
||||||
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -69,3 +135,52 @@ class HomeScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ActiveSessionCard extends StatelessWidget {
|
||||||
|
final Map<String, dynamic> session;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ActiveSessionCard({required this.session, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final mitraName = session['mitra_display_name'] as String? ?? 'Bestie';
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const CircleAvatar(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
child: Icon(Icons.chat, color: Colors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Sesi Aktif',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Sedang curhat dengan $mitraName',
|
||||||
|
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
26
client_app/lib/features/splash/splash_screen.dart
Normal file
26
client_app/lib/features/splash/splash_screen.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SplashScreen extends StatelessWidget {
|
||||||
|
const SplashScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.favorite, size: 80, color: Colors.blue),
|
||||||
|
SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Halo Bestie',
|
||||||
|
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
SizedBox(height: 32),
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import 'core/auth/auth_bloc.dart';
|
|||||||
import 'core/chat/chat_bloc.dart';
|
import 'core/chat/chat_bloc.dart';
|
||||||
import 'core/chat/session_closure_bloc.dart';
|
import 'core/chat/session_closure_bloc.dart';
|
||||||
import 'core/pairing/pairing_bloc.dart';
|
import 'core/pairing/pairing_bloc.dart';
|
||||||
|
import 'core/notifications/notification_service.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
import 'router.dart';
|
import 'router.dart';
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ 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);
|
||||||
|
NotificationService.initialize(_router);
|
||||||
_registerFcmToken();
|
_registerFcmToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'features/auth/screens/display_name_screen.dart';
|
|||||||
import 'features/auth/screens/register_screen.dart';
|
import 'features/auth/screens/register_screen.dart';
|
||||||
import 'features/auth/screens/otp_screen.dart';
|
import 'features/auth/screens/otp_screen.dart';
|
||||||
import 'features/auth/screens/force_register_screen.dart';
|
import 'features/auth/screens/force_register_screen.dart';
|
||||||
|
import 'features/splash/splash_screen.dart';
|
||||||
import 'features/home/home_screen.dart';
|
import 'features/home/home_screen.dart';
|
||||||
import 'features/chat/screens/searching_screen.dart';
|
import 'features/chat/screens/searching_screen.dart';
|
||||||
import 'features/chat/screens/bestie_found_screen.dart';
|
import 'features/chat/screens/bestie_found_screen.dart';
|
||||||
@@ -32,24 +33,27 @@ class _BlocRefreshNotifier extends ChangeNotifier {
|
|||||||
|
|
||||||
GoRouter buildRouter(AuthBloc authBloc) {
|
GoRouter buildRouter(AuthBloc authBloc) {
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: '/welcome',
|
initialLocation: '/splash',
|
||||||
refreshListenable: _BlocRefreshNotifier(authBloc),
|
refreshListenable: _BlocRefreshNotifier(authBloc),
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final authState = authBloc.state;
|
final authState = authBloc.state;
|
||||||
|
final isSplash = state.matchedLocation == '/splash';
|
||||||
final isAuthRoute = state.matchedLocation.startsWith('/auth') ||
|
final isAuthRoute = state.matchedLocation.startsWith('/auth') ||
|
||||||
state.matchedLocation == '/welcome';
|
state.matchedLocation == '/welcome';
|
||||||
|
|
||||||
// Don't redirect while loading — stay on current screen
|
// Show splash while loading
|
||||||
if (authState is AuthLoading) return null;
|
if (authState is AuthLoading) return isSplash ? null : '/splash';
|
||||||
|
|
||||||
if (authState is AuthAuthenticated || authState is AuthAnonymous) {
|
if (authState is AuthAuthenticated || authState is AuthAnonymous) {
|
||||||
return isAuthRoute ? '/home' : null;
|
return (isSplash || isAuthRoute) ? '/home' : null;
|
||||||
}
|
}
|
||||||
if (authState is AuthForceRegister) return '/auth/force-register';
|
if (authState is AuthForceRegister) return '/auth/force-register';
|
||||||
if (!isAuthRoute) return '/welcome';
|
if (!isAuthRoute && !isSplash) return '/welcome';
|
||||||
|
if (isSplash) return '/welcome';
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
|
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
|
||||||
GoRoute(path: '/welcome', builder: (_, __) => const WelcomeScreen()),
|
GoRoute(path: '/welcome', builder: (_, __) => const WelcomeScreen()),
|
||||||
GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()),
|
GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()),
|
||||||
GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()),
|
GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()),
|
||||||
@@ -66,10 +70,13 @@ 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) {
|
||||||
final extra = state.extra as Map<String, dynamic>?;
|
final extra = state.extra;
|
||||||
|
final mitraName = extra is String
|
||||||
|
? extra
|
||||||
|
: (extra is Map<String, dynamic> ? extra['mitraName'] as String? : null);
|
||||||
return ChatScreen(
|
return ChatScreen(
|
||||||
sessionId: state.pathParameters['sessionId']!,
|
sessionId: state.pathParameters['sessionId']!,
|
||||||
mitraName: extra?['mitraName'] as String? ?? 'Bestie',
|
mitraName: mitraName ?? 'Bestie',
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
GoRoute(path: '/chat/history', builder: (_, __) => const ChatHistoryScreen()),
|
GoRoute(path: '/chat/history', builder: (_, __) => const ChatHistoryScreen()),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Foundation
|
|||||||
import firebase_auth
|
import firebase_auth
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import firebase_messaging
|
import firebase_messaging
|
||||||
|
import flutter_local_notifications
|
||||||
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
|
||||||
@@ -16,6 +17,7 @@ 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"))
|
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||||
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
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"))
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.35"
|
version: "1.3.35"
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -65,6 +73,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.12"
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -206,6 +222,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.2"
|
version: "3.0.2"
|
||||||
|
flutter_local_notifications:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications
|
||||||
|
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "21.0.0"
|
||||||
|
flutter_local_notifications_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_linux
|
||||||
|
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.0.0"
|
||||||
|
flutter_local_notifications_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_platform_interface
|
||||||
|
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.0.0"
|
||||||
|
flutter_local_notifications_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_windows
|
||||||
|
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -400,6 +448,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -557,6 +613,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.7"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.11.0"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -605,6 +669,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.6.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.9.0 <4.0.0"
|
dart: ">=3.10.0 <4.0.0"
|
||||||
flutter: ">=3.35.0"
|
flutter: ">=3.38.1"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ dependencies:
|
|||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
go_router: ^13.2.1
|
go_router: ^13.2.1
|
||||||
|
flutter_local_notifications: ^21.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
flutter_local_notifications_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ android {
|
|||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,10 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,4 @@ class ApiClient {
|
|||||||
final response = await _dio.post(path, data: data);
|
final response = await _dio.post(path, data: data);
|
||||||
return response.data as Map<String, dynamic>;
|
return response.data as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Response> getStream(String path) async {
|
|
||||||
return _dio.get(path, options: Options(responseType: ResponseType.stream));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
final _auth = FirebaseAuth.instance;
|
final _auth = FirebaseAuth.instance;
|
||||||
ConfirmationResult? _webConfirmationResult;
|
ConfirmationResult? _webConfirmationResult;
|
||||||
|
|
||||||
AuthBloc({required this.apiClient}) : super(AuthInitial()) {
|
AuthBloc({required this.apiClient}) : super(AuthLoading()) {
|
||||||
on<AppStarted>(_onAppStarted);
|
on<AppStarted>(_onAppStarted);
|
||||||
on<PhoneOtpRequested>(_onPhoneOtpRequested);
|
on<PhoneOtpRequested>(_onPhoneOtpRequested);
|
||||||
on<OtpVerified>(_onOtpVerified);
|
on<OtpVerified>(_onOtpVerified);
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
|
import '../constants.dart';
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
abstract class ChatRequestEvent extends Equatable {
|
abstract class ChatRequestEvent extends Equatable {
|
||||||
@@ -21,6 +24,8 @@ class _RequestReceived extends ChatRequestEvent {
|
|||||||
List<Object?> get props => [data];
|
List<Object?> get props => [data];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ConnectionError extends ChatRequestEvent {}
|
||||||
|
|
||||||
class AcceptRequest extends ChatRequestEvent {
|
class AcceptRequest extends ChatRequestEvent {
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
AcceptRequest(this.sessionId);
|
AcceptRequest(this.sessionId);
|
||||||
@@ -70,49 +75,76 @@ class ChatRequestError extends ChatRequestState {
|
|||||||
// Bloc
|
// Bloc
|
||||||
class ChatRequestBloc extends Bloc<ChatRequestEvent, ChatRequestState> {
|
class ChatRequestBloc extends Bloc<ChatRequestEvent, ChatRequestState> {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
StreamSubscription? _sseSubscription;
|
WebSocketChannel? _channel;
|
||||||
|
StreamSubscription? _wsSubscription;
|
||||||
|
|
||||||
ChatRequestBloc({required this.apiClient}) : super(ChatRequestIdle()) {
|
ChatRequestBloc({required this.apiClient}) : super(ChatRequestIdle()) {
|
||||||
on<StartListening>(_onStartListening);
|
on<StartListening>(_onStartListening);
|
||||||
on<StopListening>(_onStopListening);
|
on<StopListening>(_onStopListening);
|
||||||
on<_RequestReceived>(_onRequestReceived);
|
on<_RequestReceived>(_onRequestReceived);
|
||||||
|
on<_ConnectionError>(_onConnectionError);
|
||||||
on<AcceptRequest>(_onAcceptRequest);
|
on<AcceptRequest>(_onAcceptRequest);
|
||||||
on<DeclineRequest>(_onDeclineRequest);
|
on<DeclineRequest>(_onDeclineRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onStartListening(StartListening event, Emitter<ChatRequestState> emit) async {
|
Future<void> _onStartListening(StartListening event, Emitter<ChatRequestState> emit) async {
|
||||||
_stopSSE();
|
_closeWebSocket();
|
||||||
emit(ChatRequestListening());
|
emit(ChatRequestListening());
|
||||||
_listenToSSE();
|
await _connectWebSocket();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onStopListening(StopListening event, Emitter<ChatRequestState> emit) async {
|
Future<void> _onStopListening(StopListening event, Emitter<ChatRequestState> emit) async {
|
||||||
_stopSSE();
|
_closeWebSocket();
|
||||||
emit(ChatRequestIdle());
|
emit(ChatRequestIdle());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _listenToSSE() {
|
Future<void> _connectWebSocket() async {
|
||||||
apiClient.getStream('/api/mitra/chat-requests/incoming').then((response) {
|
try {
|
||||||
final stream = response.data.stream as Stream<List<int>>;
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
_sseSubscription = stream
|
if (user == null) return;
|
||||||
.transform(utf8.decoder)
|
|
||||||
.transform(const LineSplitter())
|
final token = await user.getIdToken();
|
||||||
.where((line) => line.startsWith('data: '))
|
final wsUrl = ApiClient.baseUrl
|
||||||
.map((line) => jsonDecode(line.substring(6)) as Map<String, dynamic>)
|
.replaceFirst('https://', 'wss://')
|
||||||
.listen(
|
.replaceFirst('http://', 'ws://');
|
||||||
(data) => add(_RequestReceived(data)),
|
|
||||||
onError: (_) {},
|
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
|
||||||
);
|
|
||||||
}).catchError((_) {});
|
_wsSubscription = _channel!.stream.listen(
|
||||||
|
(raw) {
|
||||||
|
final data = jsonDecode(raw as String) as Map<String, dynamic>;
|
||||||
|
if (data['type'] == WsMessage.authOk) return; // Auth confirmed, no action needed
|
||||||
|
add(_RequestReceived(data));
|
||||||
|
},
|
||||||
|
onError: (_) => add(_ConnectionError()),
|
||||||
|
onDone: () => add(_ConnectionError()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Authenticate without session_id — just for receiving notifications
|
||||||
|
_channel!.sink.add(jsonEncode({
|
||||||
|
'type': WsMessage.auth,
|
||||||
|
'token': token,
|
||||||
|
}));
|
||||||
|
} catch (_) {
|
||||||
|
add(_ConnectionError());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onConnectionError(_ConnectionError event, Emitter<ChatRequestState> emit) async {
|
||||||
|
_closeWebSocket();
|
||||||
|
// Stay in listening state — FCM will still deliver notifications
|
||||||
|
if (state is! ChatRequestIdle) {
|
||||||
|
emit(ChatRequestListening());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onRequestReceived(_RequestReceived event, Emitter<ChatRequestState> emit) async {
|
Future<void> _onRequestReceived(_RequestReceived event, Emitter<ChatRequestState> emit) async {
|
||||||
final data = event.data;
|
final data = event.data;
|
||||||
final type = data['type'] as String?;
|
final type = data['type'] as String?;
|
||||||
|
|
||||||
if (type == 'chat_request') {
|
if (type == WsMessage.chatRequest) {
|
||||||
emit(ChatRequestIncoming(data['session_id'] as String));
|
emit(ChatRequestIncoming(data['session_id'] as String));
|
||||||
} else if (type == 'chat_request_closed') {
|
} else if (type == WsMessage.chatRequestClosed) {
|
||||||
// Request was taken by another mitra or cancelled
|
// Request was taken by another mitra or cancelled
|
||||||
if (state is ChatRequestIncoming) {
|
if (state is ChatRequestIncoming) {
|
||||||
emit(ChatRequestListening());
|
emit(ChatRequestListening());
|
||||||
@@ -148,14 +180,16 @@ class ChatRequestBloc extends Bloc<ChatRequestEvent, ChatRequestState> {
|
|||||||
emit(ChatRequestListening());
|
emit(ChatRequestListening());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _stopSSE() {
|
void _closeWebSocket() {
|
||||||
_sseSubscription?.cancel();
|
_wsSubscription?.cancel();
|
||||||
_sseSubscription = null;
|
_wsSubscription = null;
|
||||||
|
_channel?.sink.close();
|
||||||
|
_channel = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() {
|
Future<void> close() {
|
||||||
_stopSSE();
|
_closeWebSocket();
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
@@ -65,8 +66,14 @@ class ExtensionBloc extends Bloc<ExtensionEvent, ExtensionState> {
|
|||||||
} else {
|
} else {
|
||||||
emit(ExtensionIdle());
|
emit(ExtensionIdle());
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} on DioException catch (e) {
|
||||||
emit(ExtensionError('Gagal merespon perpanjangan.'));
|
final code = e.response?.data?['error']?['code'];
|
||||||
|
if (code == 'EXTENSION_RESOLVED') {
|
||||||
|
// Extension already timed out or resolved — move to goodbye
|
||||||
|
emit(ExtensionShowGoodbye());
|
||||||
|
} else {
|
||||||
|
emit(ExtensionError('Gagal merespon perpanjangan.'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:firebase_auth/firebase_auth.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
|
import '../constants.dart';
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
abstract class MitraChatEvent extends Equatable {
|
abstract class MitraChatEvent extends Equatable {
|
||||||
@@ -86,6 +87,7 @@ class ChatConnected extends MitraChatState {
|
|||||||
bool? sessionExpired,
|
bool? sessionExpired,
|
||||||
bool? sessionClosing,
|
bool? sessionClosing,
|
||||||
Map<String, dynamic>? extensionRequest,
|
Map<String, dynamic>? extensionRequest,
|
||||||
|
bool clearExtensionRequest = false,
|
||||||
}) {
|
}) {
|
||||||
return ChatConnected(
|
return ChatConnected(
|
||||||
messages: messages ?? this.messages,
|
messages: messages ?? this.messages,
|
||||||
@@ -93,7 +95,7 @@ class ChatConnected extends MitraChatState {
|
|||||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||||
sessionExpired: sessionExpired ?? this.sessionExpired,
|
sessionExpired: sessionExpired ?? this.sessionExpired,
|
||||||
sessionClosing: sessionClosing ?? this.sessionClosing,
|
sessionClosing: sessionClosing ?? this.sessionClosing,
|
||||||
extensionRequest: extensionRequest ?? this.extensionRequest,
|
extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,8 +123,8 @@ class ChatMessage {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.senderType,
|
required this.senderType,
|
||||||
required this.content,
|
required this.content,
|
||||||
this.type = 'text',
|
this.type = MessageType.text,
|
||||||
this.status = 'sent',
|
this.status = MessageStatus.sent,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,14 +162,27 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
|||||||
emit(ChatConnecting());
|
emit(ChatConnecting());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Check session status before connecting
|
||||||
|
final sessionInfo = await apiClient.get('/api/shared/chat/${event.sessionId}/info');
|
||||||
|
final sessionData = sessionInfo['data'] as Map<String, dynamic>?;
|
||||||
|
final sessionStatus = sessionData?['status'] as String?;
|
||||||
|
if (sessionStatus == SessionStatus.completed ||
|
||||||
|
sessionStatus == SessionStatus.cancelled ||
|
||||||
|
sessionStatus == SessionStatus.expired) {
|
||||||
|
emit(ChatError('Sesi sudah berakhir.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isClosing = sessionStatus == SessionStatus.closing;
|
||||||
|
|
||||||
final response = await apiClient.get('/api/shared/chat/${event.sessionId}/messages');
|
final response = await apiClient.get('/api/shared/chat/${event.sessionId}/messages');
|
||||||
final messagesData = response['data'] as List<dynamic>;
|
final messagesData = response['data'] as List<dynamic>;
|
||||||
final messages = messagesData.map((m) => ChatMessage(
|
final messages = messagesData.map((m) => ChatMessage(
|
||||||
id: m['id'] as String,
|
id: m['id'] as String,
|
||||||
senderType: m['sender_type'] as String,
|
senderType: m['sender_type'] as String,
|
||||||
content: m['content'] as String,
|
content: m['content'] as String,
|
||||||
type: m['type'] as String? ?? 'text',
|
type: m['type'] as String? ?? MessageType.text,
|
||||||
status: m['status'] as String? ?? 'sent',
|
status: m['status'] as String? ?? MessageStatus.sent,
|
||||||
createdAt: DateTime.parse(m['created_at'] as String),
|
createdAt: DateTime.parse(m['created_at'] as String),
|
||||||
)).toList();
|
)).toList();
|
||||||
|
|
||||||
@@ -188,12 +203,15 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
_channel!.sink.add(jsonEncode({
|
_channel!.sink.add(jsonEncode({
|
||||||
'type': 'auth',
|
'type': WsMessage.auth,
|
||||||
'token': token,
|
'token': token,
|
||||||
'session_id': event.sessionId,
|
'session_id': event.sessionId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
emit(ChatConnected(messages: messages));
|
emit(ChatConnected(
|
||||||
|
messages: messages,
|
||||||
|
sessionClosing: isClosing,
|
||||||
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(ChatError('Gagal terhubung ke chat.'));
|
emit(ChatError('Gagal terhubung ke chat.'));
|
||||||
}
|
}
|
||||||
@@ -211,7 +229,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
|||||||
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
final msg = ChatMessage(
|
final msg = ChatMessage(
|
||||||
id: tempId,
|
id: tempId,
|
||||||
senderType: 'mitra',
|
senderType: UserType.mitra,
|
||||||
content: event.content,
|
content: event.content,
|
||||||
status: 'sending',
|
status: 'sending',
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
@@ -220,7 +238,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
|||||||
emit(current.copyWith(messages: [...current.messages, msg]));
|
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||||
|
|
||||||
_channel!.sink.add(jsonEncode({
|
_channel!.sink.add(jsonEncode({
|
||||||
'type': 'message',
|
'type': WsMessage.message,
|
||||||
'content': event.content,
|
'content': event.content,
|
||||||
'_temp_id': tempId,
|
'_temp_id': tempId,
|
||||||
}));
|
}));
|
||||||
@@ -228,17 +246,17 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
|||||||
|
|
||||||
void _onSendTyping(SendTyping event, Emitter<MitraChatState> emit) {
|
void _onSendTyping(SendTyping event, Emitter<MitraChatState> emit) {
|
||||||
if (_channel == null) return;
|
if (_channel == null) return;
|
||||||
_channel!.sink.add(jsonEncode({'type': 'typing'}));
|
_channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<MitraChatState> emit) {
|
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<MitraChatState> emit) {
|
||||||
if (_channel == null) return;
|
if (_channel == null) return;
|
||||||
_channel!.sink.add(jsonEncode({'type': 'delivered', 'message_ids': event.messageIds}));
|
_channel!.sink.add(jsonEncode({'type': WsMessage.delivered, 'message_ids': event.messageIds}));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onMarkRead(MarkMessagesRead event, Emitter<MitraChatState> emit) {
|
void _onMarkRead(MarkMessagesRead event, Emitter<MitraChatState> emit) {
|
||||||
if (_channel == null) return;
|
if (_channel == null) return;
|
||||||
_channel!.sink.add(jsonEncode({'type': 'read', 'message_ids': event.messageIds}));
|
_channel!.sink.add(jsonEncode({'type': WsMessage.read, 'message_ids': event.messageIds}));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onMessageReceived(_MessageReceived event, Emitter<MitraChatState> emit) {
|
void _onMessageReceived(_MessageReceived event, Emitter<MitraChatState> emit) {
|
||||||
@@ -248,30 +266,30 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
|||||||
final type = data['type'] as String?;
|
final type = data['type'] as String?;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'auth_ok':
|
case WsMessage.authOk:
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'message':
|
case WsMessage.message:
|
||||||
final msg = ChatMessage(
|
final msg = ChatMessage(
|
||||||
id: data['message_id'] as String,
|
id: data['message_id'] as String,
|
||||||
senderType: data['sender_type'] as String,
|
senderType: data['sender_type'] as String,
|
||||||
content: data['content'] as String,
|
content: data['content'] as String,
|
||||||
type: data['message_type'] as String? ?? 'text',
|
type: data['message_type'] as String? ?? MessageType.text,
|
||||||
status: 'sent',
|
status: MessageStatus.sent,
|
||||||
createdAt: DateTime.parse(data['created_at'] as String),
|
createdAt: DateTime.parse(data['created_at'] as String),
|
||||||
);
|
);
|
||||||
emit(current.copyWith(messages: [...current.messages, msg]));
|
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||||
add(MarkMessagesDelivered([msg.id]));
|
add(MarkMessagesDelivered([msg.id]));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'message_ack':
|
case WsMessage.messageAck:
|
||||||
final messageId = data['message_id'] as String;
|
final messageId = data['message_id'] as String;
|
||||||
final status = data['status'] as String;
|
final status = data['status'] as String;
|
||||||
final updatedMessages = current.messages.map((m) {
|
final updatedMessages = current.messages.map((m) {
|
||||||
if (m.status == 'sending') return m.copyWith(status: status);
|
if (m.status == 'sending') return m.copyWith(status: status);
|
||||||
return m;
|
return m;
|
||||||
}).toList();
|
}).toList();
|
||||||
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == 'mitra');
|
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.mitra);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
final old = updatedMessages[idx];
|
final old = updatedMessages[idx];
|
||||||
updatedMessages[idx] = ChatMessage(
|
updatedMessages[idx] = ChatMessage(
|
||||||
@@ -286,7 +304,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
|||||||
emit(current.copyWith(messages: updatedMessages));
|
emit(current.copyWith(messages: updatedMessages));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'message_status':
|
case WsMessage.messageStatus:
|
||||||
final messageIds = (data['message_ids'] as List<dynamic>).cast<String>();
|
final messageIds = (data['message_ids'] as List<dynamic>).cast<String>();
|
||||||
final status = data['status'] as String;
|
final status = data['status'] as String;
|
||||||
final updatedMessages = current.messages.map((m) {
|
final updatedMessages = current.messages.map((m) {
|
||||||
@@ -296,7 +314,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
|||||||
emit(current.copyWith(messages: updatedMessages));
|
emit(current.copyWith(messages: updatedMessages));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'typing':
|
case WsMessage.typing:
|
||||||
emit(current.copyWith(isOtherTyping: true));
|
emit(current.copyWith(isOtherTyping: true));
|
||||||
_typingTimer?.cancel();
|
_typingTimer?.cancel();
|
||||||
_typingTimer = Timer(const Duration(seconds: 3), () {
|
_typingTimer = Timer(const Duration(seconds: 3), () {
|
||||||
@@ -306,27 +324,27 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session_timer':
|
case WsMessage.sessionTimer:
|
||||||
emit(current.copyWith(remainingSeconds: data['remaining_seconds'] as int?));
|
emit(current.copyWith(remainingSeconds: data['remaining_seconds'] as int?));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session_expired':
|
case WsMessage.sessionExpired:
|
||||||
emit(current.copyWith(sessionExpired: true));
|
emit(current.copyWith(sessionExpired: true));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'extension_request':
|
case WsMessage.extensionRequest:
|
||||||
emit(current.copyWith(extensionRequest: data));
|
emit(current.copyWith(extensionRequest: data));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session_resumed':
|
case WsMessage.sessionResumed:
|
||||||
emit(current.copyWith(sessionExpired: false, extensionRequest: null));
|
emit(current.copyWith(sessionExpired: false, sessionClosing: false, clearExtensionRequest: true));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session_closing':
|
case WsMessage.sessionClosing:
|
||||||
emit(current.copyWith(sessionClosing: true));
|
emit(current.copyWith(sessionClosing: true, clearExtensionRequest: true));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session_completed':
|
case WsMessage.sessionCompleted:
|
||||||
_cleanup();
|
_cleanup();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
83
mitra_app/lib/core/constants.dart
Normal file
83
mitra_app/lib/core/constants.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/// User types
|
||||||
|
class UserType {
|
||||||
|
static const customer = 'customer';
|
||||||
|
static const mitra = 'mitra';
|
||||||
|
UserType._();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chat session statuses
|
||||||
|
class SessionStatus {
|
||||||
|
static const searching = 'searching';
|
||||||
|
static const pendingAcceptance = 'pending_acceptance';
|
||||||
|
static const pendingPayment = 'pending_payment';
|
||||||
|
static const active = 'active';
|
||||||
|
static const extending = 'extending';
|
||||||
|
static const closing = 'closing';
|
||||||
|
static const completed = 'completed';
|
||||||
|
static const cancelled = 'cancelled';
|
||||||
|
static const expired = 'expired';
|
||||||
|
SessionStatus._();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chat message statuses
|
||||||
|
class MessageStatus {
|
||||||
|
static const sent = 'sent';
|
||||||
|
static const delivered = 'delivered';
|
||||||
|
static const read = 'read';
|
||||||
|
MessageStatus._();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chat message types
|
||||||
|
class MessageType {
|
||||||
|
static const text = 'text';
|
||||||
|
MessageType._();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session extension statuses
|
||||||
|
class ExtensionStatus {
|
||||||
|
static const pending = 'pending';
|
||||||
|
static const accepted = 'accepted';
|
||||||
|
static const rejected = 'rejected';
|
||||||
|
static const timeout = 'timeout';
|
||||||
|
ExtensionStatus._();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebSocket message types
|
||||||
|
class WsMessage {
|
||||||
|
// Auth
|
||||||
|
static const auth = 'auth';
|
||||||
|
static const authOk = 'auth_ok';
|
||||||
|
static const error = 'error';
|
||||||
|
|
||||||
|
// Chat
|
||||||
|
static const message = 'message';
|
||||||
|
static const messageAck = 'message_ack';
|
||||||
|
static const messageStatus = 'message_status';
|
||||||
|
static const typing = 'typing';
|
||||||
|
|
||||||
|
// Pairing
|
||||||
|
static const chatRequest = 'chat_request';
|
||||||
|
static const chatRequestClosed = 'chat_request_closed';
|
||||||
|
static const paired = 'paired';
|
||||||
|
|
||||||
|
// Session lifecycle
|
||||||
|
static const sessionTimer = 'session_timer';
|
||||||
|
static const sessionExpired = 'session_expired';
|
||||||
|
static const sessionClosing = 'session_closing';
|
||||||
|
static const sessionCompleted = 'session_completed';
|
||||||
|
static const sessionPaused = 'session_paused';
|
||||||
|
static const sessionResumed = 'session_resumed';
|
||||||
|
|
||||||
|
// Extension
|
||||||
|
static const extensionRequest = 'extension_request';
|
||||||
|
static const extensionResponse = 'extension_response';
|
||||||
|
|
||||||
|
// Delivery
|
||||||
|
static const delivered = 'delivered';
|
||||||
|
static const read = 'read';
|
||||||
|
|
||||||
|
// Early end
|
||||||
|
static const earlyEnd = 'early_end';
|
||||||
|
|
||||||
|
WsMessage._();
|
||||||
|
}
|
||||||
95
mitra_app/lib/core/notifications/notification_service.dart
Normal file
95
mitra_app/lib/core/notifications/notification_service.dart
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// Handles FCM foreground/background notifications and local notification display.
|
||||||
|
class NotificationService {
|
||||||
|
static final _localNotifications = FlutterLocalNotificationsPlugin();
|
||||||
|
static GoRouter? _router;
|
||||||
|
|
||||||
|
static const _channel = AndroidNotificationChannel(
|
||||||
|
'chat_messages',
|
||||||
|
'Chat Messages',
|
||||||
|
description: 'Notifications for incoming chat messages',
|
||||||
|
importance: Importance.high,
|
||||||
|
);
|
||||||
|
|
||||||
|
static Future<void> initialize(GoRouter router) async {
|
||||||
|
_router = router;
|
||||||
|
|
||||||
|
// Create Android notification channel
|
||||||
|
await _localNotifications
|
||||||
|
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||||
|
?.createNotificationChannel(_channel);
|
||||||
|
|
||||||
|
// Initialize local notifications
|
||||||
|
await _localNotifications.initialize(
|
||||||
|
settings: const InitializationSettings(
|
||||||
|
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||||
|
iOS: DarwinInitializationSettings(),
|
||||||
|
),
|
||||||
|
onDidReceiveNotificationResponse: _onNotificationTap,
|
||||||
|
);
|
||||||
|
|
||||||
|
// FCM foreground messages → show local notification
|
||||||
|
FirebaseMessaging.onMessage.listen(_onForegroundMessage);
|
||||||
|
|
||||||
|
// FCM notification tap (app was in background)
|
||||||
|
FirebaseMessaging.onMessageOpenedApp.listen(_onMessageOpenedApp);
|
||||||
|
|
||||||
|
// Check if app was opened from a terminated state via notification
|
||||||
|
final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
|
||||||
|
if (initialMessage != null) {
|
||||||
|
_navigateFromMessage(initialMessage.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _onForegroundMessage(RemoteMessage message) {
|
||||||
|
final notification = message.notification;
|
||||||
|
if (notification == null) return;
|
||||||
|
|
||||||
|
_localNotifications.show(
|
||||||
|
id: notification.hashCode,
|
||||||
|
title: notification.title,
|
||||||
|
body: notification.body,
|
||||||
|
notificationDetails: NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
_channel.id,
|
||||||
|
_channel.name,
|
||||||
|
channelDescription: _channel.description,
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
),
|
||||||
|
iOS: const DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
payload: jsonEncode(message.data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _onNotificationTap(NotificationResponse response) {
|
||||||
|
if (response.payload == null) return;
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(response.payload!) as Map<String, dynamic>;
|
||||||
|
_navigateFromMessage(data);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _onMessageOpenedApp(RemoteMessage message) {
|
||||||
|
_navigateFromMessage(message.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _navigateFromMessage(Map<String, dynamic> data) {
|
||||||
|
final sessionId = data['session_id'] as String?;
|
||||||
|
if (sessionId == null || _router == null) return;
|
||||||
|
|
||||||
|
final type = data['type'] as String?;
|
||||||
|
if (type == 'chat_message' || type == 'chat_request') {
|
||||||
|
_router!.push('/chat/session/$sessionId');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/api/api_client.dart';
|
import '../../../core/api/api_client.dart';
|
||||||
|
|
||||||
class ActiveSessionsScreen extends StatefulWidget {
|
class ActiveSessionsScreen extends StatefulWidget {
|
||||||
@@ -72,14 +73,19 @@ class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> {
|
|||||||
itemCount: _sessions.length,
|
itemCount: _sessions.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final session = _sessions[index];
|
final session = _sessions[index];
|
||||||
|
final customerName = session['customer_display_name'] as String? ?? 'Customer';
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const Icon(Icons.person),
|
leading: const Icon(Icons.chat),
|
||||||
title: Text(session['customer_display_name'] as String? ?? 'Customer'),
|
title: Text(customerName),
|
||||||
subtitle: Text('Status: ${session['status']}'),
|
subtitle: Text('Status: ${session['status']}'),
|
||||||
trailing: TextButton(
|
trailing: TextButton(
|
||||||
onPressed: () => _endSession(session['id'] as String),
|
onPressed: () => _endSession(session['id'] as String),
|
||||||
child: const Text('Akhiri', style: TextStyle(color: Colors.red)),
|
child: const Text('Akhiri', style: TextStyle(color: Colors.red)),
|
||||||
),
|
),
|
||||||
|
onTap: () => context.push(
|
||||||
|
'/chat/session/${session['id']}',
|
||||||
|
extra: {'customerName': customerName},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../core/api/api_client.dart';
|
import '../../../core/api/api_client.dart';
|
||||||
|
import '../../../core/constants.dart';
|
||||||
|
|
||||||
class MitraChatTranscriptScreen extends StatefulWidget {
|
class MitraChatTranscriptScreen extends StatefulWidget {
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
@@ -47,7 +48,7 @@ class _MitraChatTranscriptScreenState extends State<MitraChatTranscriptScreen> {
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
..._messages.map((m) {
|
..._messages.map((m) {
|
||||||
final isMe = m['sender_type'] == 'mitra';
|
final isMe = m['sender_type'] == UserType.mitra;
|
||||||
final time = DateTime.parse(m['created_at'] as String).toLocal();
|
final time = DateTime.parse(m['created_at'] as String).toLocal();
|
||||||
return Align(
|
return Align(
|
||||||
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
@@ -79,7 +80,7 @@ class _MitraChatTranscriptScreenState extends State<MitraChatTranscriptScreen> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
..._closures.map((c) => Card(
|
..._closures.map((c) => Card(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
title: Text(c['user_type'] == 'mitra' ? 'Kamu' : 'Customer'),
|
title: Text(c['user_type'] == UserType.mitra ? 'Kamu' : 'Customer'),
|
||||||
subtitle: Text(c['message'] as String),
|
subtitle: Text(c['message'] as String),
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/chat/mitra_chat_bloc.dart';
|
import '../../../core/chat/mitra_chat_bloc.dart';
|
||||||
import '../../../core/chat/extension_bloc.dart';
|
import '../../../core/chat/extension_bloc.dart';
|
||||||
|
import '../../../core/constants.dart';
|
||||||
|
|
||||||
class MitraChatScreen extends StatefulWidget {
|
class MitraChatScreen extends StatefulWidget {
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
@@ -20,8 +21,15 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
|
|||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
Timer? _typingThrottle;
|
Timer? _typingThrottle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
context.read<MitraChatBloc>().add(ConnectChat(widget.sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
context.read<MitraChatBloc>().add(DisconnectChat());
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_typingThrottle?.cancel();
|
_typingThrottle?.cancel();
|
||||||
@@ -63,7 +71,7 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
|
|||||||
if (state is ChatConnected) {
|
if (state is ChatConnected) {
|
||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
final unread = state.messages
|
final unread = state.messages
|
||||||
.where((m) => m.senderType == 'customer' && m.status != 'read')
|
.where((m) => m.senderType == UserType.customer && m.status != MessageStatus.read)
|
||||||
.map((m) => m.id)
|
.map((m) => m.id)
|
||||||
.toList();
|
.toList();
|
||||||
if (unread.isNotEmpty) {
|
if (unread.isNotEmpty) {
|
||||||
@@ -147,7 +155,7 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
|
|||||||
itemCount: state.messages.length,
|
itemCount: state.messages.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final msg = state.messages[index];
|
final msg = state.messages[index];
|
||||||
final isMe = msg.senderType == 'mitra';
|
final isMe = msg.senderType == UserType.mitra;
|
||||||
return _buildMessageBubble(msg, isMe);
|
return _buildMessageBubble(msg, isMe);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -204,11 +212,11 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'sending':
|
case 'sending':
|
||||||
return const Icon(Icons.access_time, size: 14, color: Colors.grey);
|
return const Icon(Icons.access_time, size: 14, color: Colors.grey);
|
||||||
case 'sent':
|
case MessageStatus.sent:
|
||||||
return const Icon(Icons.check, size: 14, color: Colors.grey);
|
return const Icon(Icons.check, size: 14, color: Colors.grey);
|
||||||
case 'delivered':
|
case MessageStatus.delivered:
|
||||||
return const Icon(Icons.done_all, size: 14, color: Colors.grey);
|
return const Icon(Icons.done_all, size: 14, color: Colors.grey);
|
||||||
case 'read':
|
case MessageStatus.read:
|
||||||
return const Icon(Icons.done_all, size: 14, color: Colors.blue);
|
return const Icon(Icons.done_all, size: 14, color: Colors.blue);
|
||||||
default:
|
default:
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
@@ -249,56 +257,64 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
|
|||||||
final duration = request['duration_minutes'] as int?;
|
final duration = request['duration_minutes'] as int?;
|
||||||
final extensionId = request['extension_id'] as String?;
|
final extensionId = request['extension_id'] as String?;
|
||||||
|
|
||||||
return Center(
|
return BlocBuilder<ExtensionBloc, ExtensionState>(
|
||||||
child: Padding(
|
builder: (context, extState) {
|
||||||
padding: const EdgeInsets.all(32),
|
final isResponding = extState is ExtensionResponding;
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
return Center(
|
||||||
children: [
|
child: Padding(
|
||||||
const Icon(Icons.timer, size: 64, color: Colors.orange),
|
padding: const EdgeInsets.all(32),
|
||||||
const SizedBox(height: 16),
|
child: Column(
|
||||||
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,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton(
|
const Icon(Icons.timer, size: 64, color: Colors.orange),
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
|
const SizedBox(height: 16),
|
||||||
onPressed: () => context.read<ExtensionBloc>().add(RespondToExtension(
|
const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
sessionId: widget.sessionId,
|
const SizedBox(height: 8),
|
||||||
extensionId: extensionId!,
|
Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center),
|
||||||
accepted: true,
|
const SizedBox(height: 24),
|
||||||
)),
|
if (isResponding)
|
||||||
child: const Text('Terima', style: TextStyle(color: Colors.white)),
|
const CircularProgressIndicator()
|
||||||
),
|
else
|
||||||
const SizedBox(width: 16),
|
Row(
|
||||||
ElevatedButton(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
children: [
|
||||||
onPressed: () => context.read<ExtensionBloc>().add(RespondToExtension(
|
ElevatedButton(
|
||||||
sessionId: widget.sessionId,
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
|
||||||
extensionId: extensionId!,
|
onPressed: extensionId == null ? null : () => context.read<ExtensionBloc>().add(RespondToExtension(
|
||||||
accepted: false,
|
sessionId: widget.sessionId,
|
||||||
)),
|
extensionId: extensionId,
|
||||||
child: const Text('Tolak', style: TextStyle(color: Colors.white)),
|
accepted: true,
|
||||||
),
|
)),
|
||||||
|
child: const Text('Terima', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
onPressed: extensionId == null ? null : () => 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) {
|
Widget _buildGoodbyeView(BuildContext context, ExtensionState extState) {
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
return Center(
|
return SingleChildScrollView(
|
||||||
child: Padding(
|
padding: const EdgeInsets.all(32),
|
||||||
padding: const EdgeInsets.all(32),
|
child: Column(
|
||||||
child: Column(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
const SizedBox(height: 48),
|
||||||
children: [
|
|
||||||
const Icon(Icons.waving_hand, size: 64, color: Colors.amber),
|
const Icon(Icons.waving_hand, size: 64, color: Colors.amber),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
@@ -331,7 +347,6 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../core/auth/auth_bloc.dart';
|
import '../../core/auth/auth_bloc.dart';
|
||||||
import '../../core/status/status_bloc.dart';
|
import '../../core/status/status_bloc.dart';
|
||||||
import '../../core/chat/chat_request_bloc.dart';
|
import '../../core/chat/chat_request_bloc.dart';
|
||||||
@@ -65,9 +66,11 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
|||||||
if (state is ChatRequestIncoming) {
|
if (state is ChatRequestIncoming) {
|
||||||
_showIncomingRequest(state.sessionId);
|
_showIncomingRequest(state.sessionId);
|
||||||
} else if (state is ChatRequestAccepted) {
|
} else if (state is ChatRequestAccepted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final session = state.session;
|
||||||
const SnackBar(content: Text('Sesi baru diterima!')),
|
final sessionId = session['session_id'] as String? ?? session['id'] as String;
|
||||||
);
|
context.push('/chat/session/$sessionId', extra: {
|
||||||
|
'customerName': session['customer_display_name'] as String? ?? 'Customer',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -177,7 +180,7 @@ class _ActiveSessionsButton extends StatelessWidget {
|
|||||||
leading: const Icon(Icons.chat_bubble_outline),
|
leading: const Icon(Icons.chat_bubble_outline),
|
||||||
title: const Text('Sesi Aktif'),
|
title: const Text('Sesi Aktif'),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => Navigator.of(context).pushNamed('/sessions'),
|
onTap: () => context.push('/sessions'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Card(
|
Card(
|
||||||
@@ -185,7 +188,7 @@ class _ActiveSessionsButton extends StatelessWidget {
|
|||||||
leading: const Icon(Icons.history),
|
leading: const Icon(Icons.history),
|
||||||
title: const Text('Riwayat Chat'),
|
title: const Text('Riwayat Chat'),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => Navigator.of(context).pushNamed('/chat/history'),
|
onTap: () => context.push('/chat/history'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
26
mitra_app/lib/features/splash/splash_screen.dart
Normal file
26
mitra_app/lib/features/splash/splash_screen.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SplashScreen extends StatelessWidget {
|
||||||
|
const SplashScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.favorite, size: 80, color: Colors.blue),
|
||||||
|
SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Halo Bestie Mitra',
|
||||||
|
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
SizedBox(height: 32),
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ 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/mitra_chat_bloc.dart';
|
||||||
import 'core/chat/extension_bloc.dart';
|
import 'core/chat/extension_bloc.dart';
|
||||||
|
import 'core/notifications/notification_service.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
import 'router.dart';
|
import 'router.dart';
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
_apiClient = ApiClient();
|
_apiClient = ApiClient();
|
||||||
_authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted());
|
_authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted());
|
||||||
_router = buildRouter(_authBloc);
|
_router = buildRouter(_authBloc);
|
||||||
|
NotificationService.initialize(_router);
|
||||||
_statusBloc = StatusBloc(apiClient: _apiClient);
|
_statusBloc = StatusBloc(apiClient: _apiClient);
|
||||||
_chatRequestBloc = ChatRequestBloc(apiClient: _apiClient);
|
_chatRequestBloc = ChatRequestBloc(apiClient: _apiClient);
|
||||||
_registerFcmToken();
|
_registerFcmToken();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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 'features/splash/splash_screen.dart';
|
||||||
import 'features/auth/screens/login_screen.dart';
|
import 'features/auth/screens/login_screen.dart';
|
||||||
import 'features/auth/screens/otp_screen.dart';
|
import 'features/auth/screens/otp_screen.dart';
|
||||||
import 'features/home/home_screen.dart';
|
import 'features/home/home_screen.dart';
|
||||||
@@ -26,19 +27,26 @@ class _BlocRefreshNotifier extends ChangeNotifier {
|
|||||||
|
|
||||||
GoRouter buildRouter(AuthBloc authBloc) {
|
GoRouter buildRouter(AuthBloc authBloc) {
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: '/login',
|
initialLocation: '/splash',
|
||||||
refreshListenable: _BlocRefreshNotifier(authBloc),
|
refreshListenable: _BlocRefreshNotifier(authBloc),
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final authState = authBloc.state;
|
final authState = authBloc.state;
|
||||||
|
final isSplash = state.matchedLocation == '/splash';
|
||||||
final isAuthRoute = state.matchedLocation.startsWith('/login') ||
|
final isAuthRoute = state.matchedLocation.startsWith('/login') ||
|
||||||
state.matchedLocation.startsWith('/otp');
|
state.matchedLocation.startsWith('/otp');
|
||||||
|
|
||||||
if (authState is AuthLoading) return null;
|
// Show splash while loading
|
||||||
if (authState is AuthAuthenticated) return isAuthRoute ? '/home' : null;
|
if (authState is AuthLoading) return isSplash ? null : '/splash';
|
||||||
if (!isAuthRoute) return '/login';
|
|
||||||
|
if (authState is AuthAuthenticated) {
|
||||||
|
return (isSplash || isAuthRoute) ? '/home' : null;
|
||||||
|
}
|
||||||
|
if (!isAuthRoute && !isSplash) return '/login';
|
||||||
|
if (isSplash) return '/login';
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
|
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
|
||||||
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
|
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
|
||||||
GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
|
GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
|
||||||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.35"
|
version: "1.3.35"
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -65,6 +73,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.12"
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -97,6 +113,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.3"
|
version: "1.3.3"
|
||||||
|
ffi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi
|
||||||
|
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
firebase_auth:
|
firebase_auth:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -190,6 +214,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.2"
|
version: "3.0.2"
|
||||||
|
flutter_local_notifications:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications
|
||||||
|
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "21.0.0"
|
||||||
|
flutter_local_notifications_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_linux
|
||||||
|
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.0.0"
|
||||||
|
flutter_local_notifications_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_platform_interface
|
||||||
|
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.0.0"
|
||||||
|
flutter_local_notifications_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_windows
|
||||||
|
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -208,6 +264,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "13.2.5"
|
version: "13.2.5"
|
||||||
|
http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -304,6 +368,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.2"
|
||||||
plugin_platform_interface:
|
plugin_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -373,6 +445,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.7"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.11.0"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -413,6 +493,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.5"
|
version: "2.4.5"
|
||||||
|
xdg_directories:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xdg_directories
|
||||||
|
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.6.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.8.0-0 <4.0.0"
|
dart: ">=3.10.0 <4.0.0"
|
||||||
flutter: ">=3.18.0-18.0.pre.54"
|
flutter: ">=3.38.1"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ dependencies:
|
|||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
go_router: ^13.2.1
|
go_router: ^13.2.1
|
||||||
|
flutter_local_notifications: ^21.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -14,6 +14,16 @@
|
|||||||
|
|
||||||
# Functional Requirement
|
# Functional Requirement
|
||||||
|
|
||||||
|
## Chat Opening
|
||||||
|
Functinal Requirement:
|
||||||
|
- Customer Application can start request chat session
|
||||||
|
- Customer App will shows dialog to choose selection of time and price. This phase it will be mock only. later it will be taken from Configuration of Control Center
|
||||||
|
- If enabled, Customer App will shows free trial instead of price selection. But this free trial only shows when following logic met:
|
||||||
|
* Free trial is enabled
|
||||||
|
* Customer never made any transaction (including free trial)
|
||||||
|
- When customer choose Free trial, the chat session period will be determine by Control Center
|
||||||
|
- After selection (either paid, or free trial), Backend will start pairing process
|
||||||
|
|
||||||
## Bi Directional Chat
|
## Bi Directional Chat
|
||||||
Functional requirement:
|
Functional requirement:
|
||||||
- After pairing success, both Mitra and Customer can communicate bidirectional
|
- After pairing success, both Mitra and Customer can communicate bidirectional
|
||||||
@@ -22,17 +32,30 @@ Functional requirement:
|
|||||||
- All chat must be recorded in database in chronological order
|
- All chat must be recorded in database in chronological order
|
||||||
- When app is opened after shutdown, it must be able to populate all conversation along
|
- When app is opened after shutdown, it must be able to populate all conversation along
|
||||||
- User (both Mitra and Customer) must be able to re read all chat history
|
- User (both Mitra and Customer) must be able to re read all chat history
|
||||||
|
- Each message will have sent (one check), delivered (double check) and read (double check with blue color) status
|
||||||
|
- When one of the person type, application will shows typing indicator
|
||||||
|
|
||||||
## Chat Closure
|
## Chat Closure
|
||||||
- Chat session is a limited time session. For now, it is 2 minutes, but make it configurable through control center
|
|
||||||
- When chat is going to end (1 minutes left), shows remaining time on the chat screen
|
- When chat is going to end (1 minutes left), shows remaining time on the chat screen
|
||||||
- When chat session is finished there will be following functionality:
|
- When chat session is finished there will be following functionality:
|
||||||
- Customer Application:
|
- Customer Application:
|
||||||
- Show dialog window whether Customer want to extend the session
|
- Show dialog window whether Customer want to extend the session
|
||||||
- If Customer do not want to extend, show closing message input
|
- If Customer do not want to extend, show closing message input
|
||||||
-
|
- If Customer want to extend, show dialog selection of time and price. This phase it will be mock only.
|
||||||
|
- Wait for Mitra confirmation, shows "Menunggu konfirmasi Bestie"
|
||||||
|
- If Mitra approve, prepare for payment process in this step
|
||||||
|
- If Mitra reject, show closing message input
|
||||||
|
- Mitra Application
|
||||||
|
- If customer do not want to extend, show closing message input
|
||||||
|
- If Customer extend, send confirmation to Mitra that customer want to extend for x minutes. Mitra can do following things:
|
||||||
|
- Accept, and session extended for x minutes
|
||||||
|
- Reject, show closing message input
|
||||||
|
|
||||||
|
## Chat History
|
||||||
|
Functional Requirement:
|
||||||
|
- On Customer App, customer can see previous chat history, along with session information such as Mitra name (bestie name) and closure message
|
||||||
|
- On Mitra App, mitra can see previous chat history, along with session information such as Customer name and closure message
|
||||||
|
- Both will able to see communication history (chat message)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user