diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 28e494d..fe97758 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,9 +1,83 @@ { "permissions": { "allow": [ - "Bash(npm init:*)", - "Bash(cmd.exe /c \"npm --version\")", - "Bash(flutter --version)" + "Bash(git clone:*)", + "Bash(shopt -s dotglob)", + "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" ] } } diff --git a/backend/package-lock.json b/backend/package-lock.json index d4afe1f..2788d59 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,11 +8,11 @@ "name": "halo-bestie-backend", "version": "1.0.0", "dependencies": { - "@fastify/cors": "^9.0.1", - "@fastify/sensible": "^5.6.0", - "@fastify/websocket": "^11.2.0", + "@fastify/cors": "^11.0.0", + "@fastify/sensible": "^6.0.0", + "@fastify/websocket": "^11.0.0", "dotenv": "^16.4.5", - "fastify": "^4.28.1", + "fastify": "^5.0.0", "firebase-admin": "^12.2.0", "ioredis": "^5.4.1", "pg": "^8.12.0", @@ -24,14 +24,24 @@ } }, "node_modules/@fastify/ajv-compiler": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", - "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "ajv": "^8.11.0", - "ajv-formats": "^2.1.1", - "fast-uri": "^2.0.0" + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" } }, "node_modules/@fastify/busboy": { @@ -41,51 +51,137 @@ "license": "MIT" }, "node_modules/@fastify/cors": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz", - "integrity": "sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "fastify-plugin": "^4.0.0", - "mnemonist": "0.39.6" + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" } }, "node_modules/@fastify/error": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", - "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT" }, "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", - "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "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", "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": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", - "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "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": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-5.6.0.tgz", - "integrity": "sha512-Vq6Z2ZQy10GDqON+hvLF52K99s9et5gVVxTul5n3SIAf0Kq5QjPRUKkAMT3zPAiiGvoHtS3APa/3uaxfDgCODQ==", + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-6.0.4.tgz", + "integrity": "sha512-1vxcCUlPMew6WroK8fq+LVOwbsLtX+lmuRuqpcp6eYqu6vmkLwbKTdBWAZwbeaSgCfW4tzUpTIHLLvTiQQ1BwQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "@lukeed/ms": "^2.0.1", - "fast-deep-equal": "^3.1.1", - "fastify-plugin": "^4.0.0", + "@lukeed/ms": "^2.0.2", + "dequal": "^2.0.3", + "fastify-plugin": "^5.0.0", "forwarded": "^0.2.0", "http-errors": "^2.0.0", - "type-is": "^1.6.18", + "type-is": "^2.0.1", "vary": "^1.1.2" } }, @@ -110,22 +206,6 @@ "ws": "^8.16.0" } }, - "node_modules/@fastify/websocket/node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@firebase/app-check-interop-types": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.2.tgz", @@ -594,9 +674,9 @@ } }, "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "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" @@ -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": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -689,12 +753,22 @@ } }, "node_modules/avvio": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", - "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "@fastify/error": "^3.3.0", + "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, @@ -806,15 +880,28 @@ "node": ">= 0.8" } }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "license": "MIT", "engines": { "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": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -860,6 +947,15 @@ "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": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1009,12 +1105,6 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -1028,35 +1118,27 @@ "license": "MIT" }, "node_modules/fast-json-stringify": { - "version": "5.16.1", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", - "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", - "license": "MIT", - "dependencies": { - "@fastify/merge-json-schemas": "^0.1.0", - "ajv": "^8.10.0", - "ajv-formats": "^3.0.1", - "fast-deep-equal": "^3.1.3", - "fast-uri": "^2.1.0", - "json-schema-ref-resolver": "^1.0.1", - "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 + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" } + ], + "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": { @@ -1069,10 +1151,20 @@ } }, "node_modules/fast-uri": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", - "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", - "license": "MIT" + "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/fast-xml-builder": { "version": "1.1.4", @@ -1112,9 +1204,9 @@ } }, "node_modules/fastify": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", - "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", + "version": "5.8.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz", + "integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==", "funding": [ { "type": "github", @@ -1127,28 +1219,37 @@ ], "license": "MIT", "dependencies": { - "@fastify/ajv-compiler": "^3.5.0", - "@fastify/error": "^3.4.0", - "@fastify/fast-json-stringify-compiler": "^4.3.0", + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", - "avvio": "^8.3.0", - "fast-content-type-parse": "^1.1.0", - "fast-json-stringify": "^5.8.0", - "find-my-way": "^8.0.0", - "light-my-request": "^5.11.0", - "pino": "^9.0.0", - "process-warning": "^3.0.0", - "proxy-addr": "^2.0.7", - "rfdc": "^1.3.0", - "secure-json-parse": "^2.7.0", - "semver": "^7.5.4", - "toad-cache": "^3.3.0" + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" } }, "node_modules/fastify-plugin": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", - "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", + "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/fastq": { @@ -1173,17 +1274,17 @@ } }, "node_modules/find-my-way": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", - "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", - "safe-regex2": "^3.1.0" + "safe-regex2": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/firebase-admin": { @@ -1615,12 +1716,12 @@ } }, "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 10" } }, "node_modules/is-fullwidth-code-point": { @@ -1666,12 +1767,22 @@ } }, "node_modules/json-schema-ref-resolver": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", - "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3" + "dequal": "^2.0.3" } }, "node_modules/json-schema-traverse": { @@ -1740,16 +1851,42 @@ } }, "node_modules/light-my-request": { - "version": "5.14.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", - "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "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", "dependencies": { - "cookie": "^0.7.0", - "process-warning": "^3.0.0", - "set-cookie-parser": "^2.4.1" + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "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": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", @@ -1862,12 +1999,12 @@ } }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/mime": { @@ -1888,6 +2025,7 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", + "optional": true, "engines": { "node": ">= 0.6" } @@ -1897,6 +2035,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", + "optional": true, "dependencies": { "mime-db": "1.52.0" }, @@ -1904,15 +2043,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": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1959,12 +2089,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": { "version": "2.1.2", "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==", "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": { "version": "3.4.9", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", @@ -2211,9 +2319,19 @@ } }, "node_modules/process-warning": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", - "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "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/proto3-json-serializer": { @@ -2254,19 +2372,6 @@ "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": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -2337,9 +2442,9 @@ } }, "node_modules/ret": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", - "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", "license": "MIT", "engines": { "node": ">=10" @@ -2407,12 +2512,25 @@ "license": "MIT" }, "node_modules/safe-regex2": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", - "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz", + "integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "ret": "~0.4.0" + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" } }, "node_modules/safe-stable-stringify": { @@ -2425,9 +2543,19 @@ } }, "node_modules/secure-json-parse": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", - "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "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" }, "node_modules/semver": { @@ -2659,18 +2787,44 @@ "license": "0BSD" }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { "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": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", diff --git a/backend/package.json b/backend/package.json index ac4b8c2..d77e69a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,11 +11,11 @@ "db:seed": "node src/db/seed.js" }, "dependencies": { - "@fastify/cors": "^9.0.1", - "@fastify/sensible": "^5.6.0", - "@fastify/websocket": "^11.2.0", + "@fastify/cors": "^11.0.0", + "@fastify/sensible": "^6.0.0", + "@fastify/websocket": "^11.0.0", "dotenv": "^16.4.5", - "fastify": "^4.28.1", + "fastify": "^5.0.0", "firebase-admin": "^12.2.0", "ioredis": "^5.4.1", "pg": "^8.12.0", diff --git a/backend/src/constants.js b/backend/src/constants.js new file mode 100644 index 0000000..ae06a8c --- /dev/null +++ b/backend/src/constants.js @@ -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', +}) diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index 493da0e..d448caf 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -261,6 +261,19 @@ const migrate = async () => { 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.') await sql.end() } diff --git a/backend/src/plugins/websocket.js b/backend/src/plugins/websocket.js index e3326c0..c06dc5d 100644 --- a/backend/src/plugins/websocket.js +++ b/backend/src/plugins/websocket.js @@ -3,6 +3,7 @@ import { verifyFirebaseToken } from './firebase.js' import { getCustomerByFirebaseUid } from '../services/customer.service.js' import { getMitraByFirebaseUid } from '../services/mitra.service.js' import { subscribe, publish } from './valkey.js' +import { UserType, WsMessage } from '../constants.js' // Track active WebSocket connections: sessionId → { customer, mitra } const sessionConnections = new Map() @@ -56,24 +57,24 @@ export const registerWebSocketRoute = (app) => { try { msg = JSON.parse(raw.toString()) } catch { - send({ type: 'error', message: 'Invalid JSON' }) + send({ type: WsMessage.ERROR, message: 'Invalid JSON' }) return } // Handle auth message - if (msg.type === 'auth') { + if (msg.type === WsMessage.AUTH) { try { const decoded = await verifyFirebaseToken(msg.token) const customer = await getCustomerByFirebaseUid(decoded.uid) const mitra = customer ? null : await getMitraByFirebaseUid(decoded.uid) if (!customer && !mitra) { - send({ type: 'error', message: 'Account not found' }) + send({ type: WsMessage.ERROR, message: 'Account not found' }) socket.close() return } - const userType = customer ? 'customer' : 'mitra' + const userType = customer ? UserType.CUSTOMER : UserType.MITRA const userId = customer ? customer.id : mitra.id const sessionId = msg.session_id @@ -101,9 +102,9 @@ export const registerWebSocketRoute = (app) => { 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) { - send({ type: 'error', message: 'Authentication failed' }) + send({ type: WsMessage.ERROR, message: 'Authentication failed' }) socket.close() } return @@ -111,7 +112,7 @@ export const registerWebSocketRoute = (app) => { // All other messages require authentication if (!authenticatedUser) { - send({ type: 'error', message: 'Not authenticated. Send auth message first.' }) + send({ type: WsMessage.ERROR, message: 'Not authenticated. Send auth message first.' }) return } @@ -135,7 +136,7 @@ export const registerWebSocketRoute = (app) => { const conns = sessionConnections.get(authenticatedUser.sessionId) if (conns) { delete conns[authenticatedUser.type] - if (!conns.customer && !conns.mitra) { + if (!conns[UserType.CUSTOMER] && !conns[UserType.MITRA]) { sessionConnections.delete(authenticatedUser.sessionId) } } diff --git a/backend/src/routes/internal/config.routes.js b/backend/src/routes/internal/config.routes.js index 590417e..87f726d 100644 --- a/backend/src/routes/internal/config.routes.js +++ b/backend/src/routes/internal/config.routes.js @@ -101,4 +101,31 @@ export const internalConfigRoutes = async (app) => { const config = await setEarlyEndConfig({ mitra_enabled, customer_enabled }) 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 }) + }) } diff --git a/backend/src/routes/public/client.chat.routes.js b/backend/src/routes/public/client.chat.routes.js index 4cd9345..39922cb 100644 --- a/backend/src/routes/public/client.chat.routes.js +++ b/backend/src/routes/public/client.chat.routes.js @@ -1,10 +1,10 @@ import { authenticate } from '../../plugins/auth.js' import { getCustomerByFirebaseUid } from '../../services/customer.service.js' -import { createPairingRequest, cancelPairingRequest, getSessionStatus } from '../../services/pairing.service.js' +import { createPairingRequest, cancelPairingRequest } from '../../services/pairing.service.js' import { getActiveSessionByCustomer, endSession, getCustomerHistory } from '../../services/session.service.js' -import { subscribe } from '../../plugins/valkey.js' import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js' import { requestExtension } from '../../services/extension.service.js' +import { EndedBy } from '../../constants.js' const resolveCustomer = async (request, reply) => { 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({ success: false, 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 }) }) - app.get('/request/:sessionId/status', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { - const { sessionId } = request.params - - // SSE stream for real-time status updates - reply.raw.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }) - - // Send current status immediately - const current = await getSessionStatus(sessionId) - if (current) { - reply.raw.write(`data: ${JSON.stringify(current)}\n\n`) - } - - // If already in a terminal state, close - if (current && ['active', 'completed', 'cancelled', 'expired'].includes(current.status)) { - reply.raw.end() - return - } - - // Subscribe to status updates - const unsubscribe = subscribe(`session:${sessionId}:status`, (data) => { - reply.raw.write(`data: ${JSON.stringify(data)}\n\n`) - if (['paired', 'expired', 'session_ended'].includes(data.type)) { - reply.raw.end() - unsubscribe() - } - }) - - // Clean up on client disconnect - request.raw.on('close', () => { - unsubscribe() - }) - }) - app.post('/request/:sessionId/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { const session = await cancelPairingRequest(request.params.sessionId, request.customer.id) return reply.send({ success: true, data: session }) @@ -111,7 +74,7 @@ export const clientChatRoutes = async (app) => { }) 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 }) }) diff --git a/backend/src/routes/public/mitra.chat.routes.js b/backend/src/routes/public/mitra.chat.routes.js index 61dfcb3..347b3ca 100644 --- a/backend/src/routes/public/mitra.chat.routes.js +++ b/backend/src/routes/public/mitra.chat.routes.js @@ -2,8 +2,8 @@ import { authenticate } from '../../plugins/auth.js' import { getMitraByFirebaseUid } from '../../services/mitra.service.js' import { acceptPairingRequest, declinePairingRequest } from '../../services/pairing.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 { EndedBy } from '../../constants.js' const resolveMitra = async (request, reply) => { const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid) @@ -23,31 +23,6 @@ const resolveMitra = async (request, reply) => { } export const mitraChatRoutes = async (app) => { - app.get('/incoming', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { - const mitraId = request.mitra.id - - // SSE stream for incoming chat requests - reply.raw.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }) - - // Keep-alive ping - const pingInterval = setInterval(() => { - reply.raw.write(': ping\n\n') - }, 15_000) - - const unsubscribe = subscribe(`mitra:${mitraId}:requests`, (data) => { - reply.raw.write(`data: ${JSON.stringify(data)}\n\n`) - }) - - request.raw.on('close', () => { - clearInterval(pingInterval) - unsubscribe() - }) - }) - app.post('/:sessionId/accept', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { const session = await acceptPairingRequest(request.params.sessionId, request.mitra.id) return reply.send({ success: true, data: session }) @@ -64,7 +39,7 @@ export const mitraChatRoutes = async (app) => { }) 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 }) }) diff --git a/backend/src/routes/public/shared.chat.routes.js b/backend/src/routes/public/shared.chat.routes.js index 01fe747..2b02e32 100644 --- a/backend/src/routes/public/shared.chat.routes.js +++ b/backend/src/routes/public/shared.chat.routes.js @@ -4,17 +4,21 @@ import { getMitraByFirebaseUid } from '../../services/mitra.service.js' import { getMessages } from '../../services/chat.service.js' import { getSessionClosures } from '../../services/closure.service.js' import { registerDeviceToken } from '../../services/notification.service.js' +import { getDb } from '../../db/client.js' +import { UserType } from '../../constants.js' + +const sql = getDb() const resolveUser = async (request, reply) => { const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid) if (customer) { - request.userType = 'customer' + request.userType = UserType.CUSTOMER request.userId = customer.id return } const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid) if (mitra) { - request.userType = 'mitra' + request.userType = UserType.MITRA request.userId = mitra.id 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) => { // 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 { limit, before } = request.query const messages = await getMessages(sessionId, { @@ -37,7 +57,7 @@ export const sharedChatRoutes = async (app) => { }) // 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 { getSessionById } = await import('../../services/session.service.js') const session = await getSessionById(sessionId) @@ -48,7 +68,7 @@ export const sharedChatRoutes = async (app) => { }) // 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 messages = await getMessages(sessionId, { limit: 10000 }) const closures = await getSessionClosures(sessionId) @@ -66,7 +86,7 @@ export const sharedChatRoutes = async (app) => { }) // 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 { message } = request.body if (!message) { diff --git a/backend/src/services/chat-handler.service.js b/backend/src/services/chat-handler.service.js index a3d4239..743bfea 100644 --- a/backend/src/services/chat-handler.service.js +++ b/backend/src/services/chat-handler.service.js @@ -2,6 +2,7 @@ import { subscribe } from '../plugins/valkey.js' import { sendMessage, markDelivered, markRead } from './chat.service.js' import { initiateEarlyEnd } from './closure.service.js' import { sendToSessionParticipant } from '../plugins/websocket.js' +import { UserType, MessageType, WsMessage } from '../constants.js' // Track typing throttle per session+user const typingLastSent = new Map() @@ -18,36 +19,36 @@ export const startSessionListener = (sessionId) => { try { switch (type) { - case 'message': + case WsMessage.MESSAGE: await sendMessage({ sessionId: _session_id, senderType: _sender_type, senderId: _sender_id, content: payload.content, - type: payload.message_type || 'text', + type: payload.message_type || MessageType.TEXT, }) break - case 'typing': + case WsMessage.TYPING: handleTyping(_session_id, _sender_type) break - case 'delivered': + case WsMessage.DELIVERED: await markDelivered(_session_id, _sender_type, payload.message_ids) break - case 'read': + case WsMessage.READ: await markRead(_session_id, _sender_type, payload.message_ids) break - case 'early_end': + case WsMessage.EARLY_END: await initiateEarlyEnd(_session_id, _sender_type) break } } catch (err) { console.error(`[chat-handler] Error processing ${type}:`, err.message) sendToSessionParticipant(_session_id, _sender_type, { - type: 'error', + type: WsMessage.ERROR, message: err.message, code: err.code, }) @@ -74,9 +75,9 @@ const handleTyping = (sessionId, senderType) => { typingLastSent.set(key, now) - const recipientType = senderType === 'customer' ? 'mitra' : 'customer' + const recipientType = senderType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER sendToSessionParticipant(sessionId, recipientType, { - type: 'typing', + type: WsMessage.TYPING, sender_type: senderType, }) } diff --git a/backend/src/services/chat.service.js b/backend/src/services/chat.service.js index f926465..cf4c067 100644 --- a/backend/src/services/chat.service.js +++ b/backend/src/services/chat.service.js @@ -2,14 +2,15 @@ import { getDb } from '../db/client.js' import { publish } from '../plugins/valkey.js' import { isUserOnlineWs, sendToSessionParticipant } from '../plugins/websocket.js' import { sendPushNotification } from './notification.service.js' +import { UserType, SessionStatus, MessageStatus, MessageType, WsMessage } from '../constants.js' 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 const [session] = await sql` 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) { throw Object.assign(new Error('Session is not active'), { @@ -20,25 +21,25 @@ export const sendMessage = async ({ sessionId, senderType, senderId, content, ty // Save message const [message] = await sql` INSERT INTO chat_messages (session_id, sender_type, sender_id, type, content, status) - VALUES (${sessionId}, ${senderType}, ${senderId}, ${type}, ${content}, 'sent') + VALUES (${sessionId}, ${senderType}, ${senderId}, ${type}, ${content}, ${MessageStatus.SENT}) RETURNING id, session_id, sender_type, sender_id, type, content, status, created_at ` // Send ack to sender sendToSessionParticipant(sessionId, senderType, { - type: 'message_ack', + type: WsMessage.MESSAGE_ACK, message_id: message.id, - status: 'sent', + status: MessageStatus.SENT, created_at: message.created_at, }) // Determine recipient - const recipientType = senderType === 'customer' ? 'mitra' : 'customer' - const recipientId = senderType === 'customer' ? session.mitra_id : session.customer_id + const recipientType = senderType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER + const recipientId = senderType === UserType.CUSTOMER ? session.mitra_id : session.customer_id // Try to send via WebSocket const delivered = sendToSessionParticipant(sessionId, recipientType, { - type: 'message', + type: WsMessage.MESSAGE, message_id: message.id, sender_type: senderType, 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 (!delivered && 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, data: { session_id: sessionId, type: 'chat_message' }, }) @@ -63,18 +64,18 @@ export const markDelivered = async (sessionId, senderType, messageIds) => { await sql` UPDATE chat_messages - SET status = 'delivered', delivered_at = NOW() + SET status = ${MessageStatus.DELIVERED}, delivered_at = NOW() WHERE id = ANY(${messageIds}) AND session_id = ${sessionId} - AND status = 'sent' + AND status = ${MessageStatus.SENT} ` // Notify sender about delivery - const recipientType = senderType === 'customer' ? 'mitra' : 'customer' + const recipientType = senderType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER sendToSessionParticipant(sessionId, recipientType, { - type: 'message_status', + type: WsMessage.MESSAGE_STATUS, message_ids: messageIds, - status: 'delivered', + status: MessageStatus.DELIVERED, }) } @@ -83,18 +84,18 @@ export const markRead = async (sessionId, senderType, messageIds) => { await sql` UPDATE chat_messages - SET status = 'read', read_at = NOW() + SET status = ${MessageStatus.READ}, read_at = NOW() WHERE id = ANY(${messageIds}) AND session_id = ${sessionId} - AND status IN ('sent', 'delivered') + AND status IN (${MessageStatus.SENT}, ${MessageStatus.DELIVERED}) ` // Notify sender about read - const recipientType = senderType === 'customer' ? 'mitra' : 'customer' + const recipientType = senderType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER sendToSessionParticipant(sessionId, recipientType, { - type: 'message_status', + type: WsMessage.MESSAGE_STATUS, 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) => { - const senderType = recipientType === 'customer' ? 'mitra' : 'customer' + const senderType = recipientType === UserType.CUSTOMER ? UserType.MITRA : UserType.CUSTOMER return sql` SELECT id, session_id, sender_type, sender_id, type, content, status, created_at FROM chat_messages WHERE session_id = ${sessionId} AND sender_type = ${senderType} - AND status = 'sent' + AND status = ${MessageStatus.SENT} ORDER BY created_at ASC ` } diff --git a/backend/src/services/closure.service.js b/backend/src/services/closure.service.js index 3a919fc..814bc70 100644 --- a/backend/src/services/closure.service.js +++ b/backend/src/services/closure.service.js @@ -1,7 +1,8 @@ import { getDb } from '../db/client.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 { UserType, SessionStatus, EndedBy, WsMessage } from '../constants.js' 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) const [session] = await sql` 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) { 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` SELECT user_type FROM session_closures WHERE session_id = ${sessionId} ` - const hasCustomer = closures.some((c) => c.user_type === 'customer') - const hasMitra = closures.some((c) => c.user_type === 'mitra') + const hasCustomer = closures.some((c) => c.user_type === UserType.CUSTOMER) + const hasMitra = closures.some((c) => c.user_type === UserType.MITRA) if (hasCustomer && hasMitra) { // Both submitted — complete the session @@ -42,28 +43,29 @@ export const submitClosureMessage = async (sessionId, userType, userId, message) export const completeSession = async (sessionId) => { clearSessionTimer(sessionId) + clearClosureGraceTimer(sessionId) const [session] = await sql` UPDATE chat_sessions - SET status = 'completed', ended_at = NOW(), ended_by = 'system' - WHERE id = ${sessionId} AND status IN ('closing', 'active', 'extending') + SET status = ${SessionStatus.COMPLETED}, ended_at = NOW(), ended_by = ${EndedBy.SYSTEM} + WHERE id = ${sessionId} AND status IN (${SessionStatus.CLOSING}, ${SessionStatus.ACTIVE}, ${SessionStatus.EXTENDING}) RETURNING id, customer_id, mitra_id, status, ended_at ` if (!session) return null // Notify both parties - const data = { type: 'session_completed', session_id: sessionId } - sendToSessionParticipant(sessionId, 'customer', data) - sendToSessionParticipant(sessionId, 'mitra', data) + const data = { type: WsMessage.SESSION_COMPLETED, session_id: sessionId } + sendToSessionParticipant(sessionId, UserType.CUSTOMER, 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 } export const initiateEarlyEnd = async (sessionId, userType) => { // Check if early end is enabled for this user type - const configKey = userType === 'mitra' ? 'early_end_mitra_enabled' : 'early_end_customer_enabled' + const 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 enabled = configRow?.value?.value ?? false @@ -76,8 +78,8 @@ export const initiateEarlyEnd = async (sessionId, userType) => { // Move session to closing const [session] = await sql` UPDATE chat_sessions - SET status = 'closing', ended_by = ${userType} - WHERE id = ${sessionId} AND status = 'active' + SET status = ${SessionStatus.CLOSING}, ended_by = ${userType} + WHERE id = ${sessionId} AND status = ${SessionStatus.ACTIVE} RETURNING id, customer_id, mitra_id ` if (!session) { @@ -89,9 +91,9 @@ export const initiateEarlyEnd = async (sessionId, userType) => { clearSessionTimer(sessionId) // Notify both parties to enter closure flow - const data = { type: 'session_closing', session_id: sessionId, ended_by: userType } - sendToSessionParticipant(sessionId, 'customer', data) - sendToSessionParticipant(sessionId, 'mitra', data) + const data = { type: WsMessage.SESSION_CLOSING, session_id: sessionId, ended_by: userType } + sendToSessionParticipant(sessionId, UserType.CUSTOMER, data) + sendToSessionParticipant(sessionId, UserType.MITRA, data) return session } diff --git a/backend/src/services/dashboard.service.js b/backend/src/services/dashboard.service.js index 01a348e..17bafaf 100644 --- a/backend/src/services/dashboard.service.js +++ b/backend/src/services/dashboard.service.js @@ -1,18 +1,19 @@ import { getDb } from '../db/client.js' +import { SessionStatus } from '../constants.js' const sql = getDb() export const getDashboardStats = async () => { const [[{ active_chats }], [{ online_mitras }], [{ pending_requests }]] = await Promise.all([ - sql`SELECT COUNT(*) AS active_chats FROM chat_sessions WHERE status IN ('active', 'pending_payment')`, + sql`SELECT COUNT(*) AS 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 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` SELECT m.id, m.display_name, (SELECT COUNT(*) FROM chat_sessions cs - WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment')) AS active_session_count + WHERE cs.mitra_id = m.id AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})) AS active_session_count FROM mitras m INNER JOIN mitra_online_status s ON s.mitra_id = m.id WHERE s.is_online = true diff --git a/backend/src/services/extension.service.js b/backend/src/services/extension.service.js index 08c4fec..82ad187 100644 --- a/backend/src/services/extension.service.js +++ b/backend/src/services/extension.service.js @@ -1,7 +1,8 @@ import { getDb } from '../db/client.js' import { publish } from '../plugins/valkey.js' import { sendToSessionParticipant } from '../plugins/websocket.js' -import { extendSessionTimer } from './session-timer.service.js' +import { extendSessionTimer, clearClosureGraceTimer } from './session-timer.service.js' +import { UserType, SessionStatus, ExtensionStatus, TransactionType, WsMessage } from '../constants.js' const sql = getDb() @@ -14,28 +15,29 @@ const getExtensionTimeout = async () => { } 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` SELECT id, customer_id, mitra_id, status FROM chat_sessions WHERE id = ${sessionId} AND customer_id = ${customerId} + AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.CLOSING}) ` 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 const [extension] = await sql` 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 ` // 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 - sendToSessionParticipant(sessionId, 'mitra', { - type: 'extension_request', + sendToSessionParticipant(sessionId, UserType.MITRA, { + type: WsMessage.EXTENSION_REQUEST, extension_id: extension.id, session_id: sessionId, duration_minutes, @@ -43,8 +45,8 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes }) // Notify customer that chat is paused - sendToSessionParticipant(sessionId, 'customer', { - type: 'session_paused', + sendToSessionParticipant(sessionId, UserType.CUSTOMER, { + type: WsMessage.SESSION_PAUSED, session_id: sessionId, reason: 'extension_pending', }) @@ -62,12 +64,20 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes } 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` UPDATE session_extensions 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 ` @@ -85,43 +95,50 @@ export const respondToExtension = async (extensionId, sessionId, mitraId, accept } if (accepted) { + // Clear any pending grace timer from the previous expiry + clearClosureGraceTimer(sessionId) + // Extend the session await extendSessionTimer(extension.session_id, extension.requested_duration_minutes) // Resume session - await sql`UPDATE chat_sessions SET status = 'active' WHERE id = ${extension.session_id}` + await sql`UPDATE chat_sessions SET status = ${SessionStatus.ACTIVE} WHERE id = ${extension.session_id}` // Record transaction await sql` INSERT INTO customer_transactions (customer_id, session_id, type, amount) - SELECT customer_id, id, 'extension', ${extension.requested_price} + SELECT customer_id, id, ${TransactionType.EXTENSION}, ${extension.requested_price} FROM chat_sessions WHERE id = ${extension.session_id} ` // Notify both parties - sendToSessionParticipant(sessionId, 'customer', { - type: 'extension_response', + sendToSessionParticipant(sessionId, UserType.CUSTOMER, { + type: WsMessage.EXTENSION_RESPONSE, accepted: true, duration_minutes: extension.requested_duration_minutes, }) - sendToSessionParticipant(sessionId, 'mitra', { - type: 'session_resumed', + sendToSessionParticipant(sessionId, UserType.CUSTOMER, { + type: WsMessage.SESSION_RESUMED, + session_id: sessionId, + }) + sendToSessionParticipant(sessionId, UserType.MITRA, { + type: WsMessage.SESSION_RESUMED, session_id: sessionId, }) } else { // 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', { - type: 'extension_response', + sendToSessionParticipant(sessionId, UserType.CUSTOMER, { + type: WsMessage.EXTENSION_RESPONSE, accepted: false, }) - sendToSessionParticipant(sessionId, 'mitra', { - type: 'session_closing', + sendToSessionParticipant(sessionId, UserType.MITRA, { + type: WsMessage.SESSION_CLOSING, session_id: sessionId, }) - sendToSessionParticipant(sessionId, 'customer', { - type: 'session_closing', + sendToSessionParticipant(sessionId, UserType.CUSTOMER, { + type: WsMessage.SESSION_CLOSING, session_id: sessionId, }) } @@ -134,26 +151,26 @@ const timeoutExtension = async (extensionId, sessionId) => { const [extension] = await sql` UPDATE session_extensions - SET status = 'timeout', responded_at = NOW() - WHERE id = ${extensionId} AND status = 'pending' + SET status = ${ExtensionStatus.TIMEOUT}, responded_at = NOW() + WHERE id = ${extensionId} AND status = ${ExtensionStatus.PENDING} RETURNING id, session_id ` if (!extension) return // Timeout = proceed to closure - await sql`UPDATE chat_sessions SET status = 'closing' WHERE id = ${extension.session_id}` + await sql`UPDATE chat_sessions SET status = ${SessionStatus.CLOSING} WHERE id = ${extension.session_id}` - sendToSessionParticipant(sessionId, 'customer', { - type: 'extension_response', + sendToSessionParticipant(sessionId, UserType.CUSTOMER, { + type: WsMessage.EXTENSION_RESPONSE, accepted: false, reason: 'timeout', }) - sendToSessionParticipant(sessionId, 'mitra', { - type: 'session_closing', + sendToSessionParticipant(sessionId, UserType.MITRA, { + type: WsMessage.SESSION_CLOSING, session_id: sessionId, }) - sendToSessionParticipant(sessionId, 'customer', { - type: 'session_closing', + sendToSessionParticipant(sessionId, UserType.CUSTOMER, { + type: WsMessage.SESSION_CLOSING, session_id: sessionId, }) } diff --git a/backend/src/services/mitra-status.service.js b/backend/src/services/mitra-status.service.js index 7bfae4a..1c0e845 100644 --- a/backend/src/services/mitra-status.service.js +++ b/backend/src/services/mitra-status.service.js @@ -1,4 +1,5 @@ import { getDb } from '../db/client.js' +import { SessionStatus } from '../constants.js' const sql = getDb() @@ -64,7 +65,7 @@ export const getOnlineMitras = async () => { const mitras = await sql` SELECT m.id, m.display_name, m.phone, s.last_online_at, s.updated_at, (SELECT COUNT(*) FROM chat_sessions cs - WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment')) AS active_session_count + WHERE cs.mitra_id = m.id AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})) AS active_session_count FROM mitras m INNER JOIN mitra_online_status s ON s.mitra_id = m.id WHERE s.is_online = true AND m.is_active = true diff --git a/backend/src/services/notification.service.js b/backend/src/services/notification.service.js index 51e76c3..b418f70 100644 --- a/backend/src/services/notification.service.js +++ b/backend/src/services/notification.service.js @@ -1,10 +1,11 @@ import admin from 'firebase-admin' import { getDb } from '../db/client.js' +import { UserType } from '../constants.js' const sql = getDb() export const registerDeviceToken = async (userType, userId, fcmToken) => { - const table = userType === 'customer' ? 'customers' : 'mitras' + const table = userType === UserType.CUSTOMER ? 'customers' : 'mitras' await sql` UPDATE ${sql(table)} SET fcm_token = ${fcmToken} @@ -13,7 +14,7 @@ export const registerDeviceToken = async (userType, userId, fcmToken) => { } 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` SELECT fcm_token FROM ${sql(table)} WHERE id = ${recipientId} ` diff --git a/backend/src/services/pairing.service.js b/backend/src/services/pairing.service.js index 3bd3b92..27d593f 100644 --- a/backend/src/services/pairing.service.js +++ b/backend/src/services/pairing.service.js @@ -1,14 +1,51 @@ import { getDb } from '../db/client.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 { startSessionListener } from './chat-handler.service.js' +import { UserType, SessionStatus, NotificationResponse, TransactionType, WsMessage } from '../constants.js' const sql = getDb() // Timeout map for active pairing requests (sessionId → timeoutId) 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 () => { const { max_customers_per_mitra } = await getMaxCustomersPerMitra() const mitras = await sql` @@ -19,7 +56,7 @@ export const findAvailableMitras = async () => { AND s.is_online = true AND ( SELECT COUNT(*) FROM chat_sessions cs - WHERE cs.mitra_id = m.id AND cs.status IN ('active', 'pending_payment') + WHERE cs.mitra_id = m.id AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}) ) < ${max_customers_per_mitra} ` return mitras @@ -30,7 +67,7 @@ export const createPairingRequest = async (customerId, { duration_minutes, price const [existing] = await sql` SELECT id, status FROM chat_sessions 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) { 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 const [session] = await sql` 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 ` @@ -58,9 +95,9 @@ export const createPairingRequest = async (customerId, { duration_minutes, price INSERT INTO chat_request_notifications (session_id, mitra_id) VALUES (${session.id}, ${mitra.id}) ` - // Publish to mitra's channel - await publish(`mitra:${mitra.id}:requests`, { - type: 'chat_request', + // Notify mitra via WebSocket (FCM fallback if offline) + await notifyMitra(mitra.id, { + type: WsMessage.CHAT_REQUEST, session_id: session.id, 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 const [session] = await sql` UPDATE chat_sessions - SET mitra_id = ${mitraId}, status = 'pending_payment', paired_at = NOW() - WHERE id = ${sessionId} AND status = 'pending_acceptance' AND mitra_id IS NULL + SET mitra_id = ${mitraId}, status = ${SessionStatus.PENDING_PAYMENT}, paired_at = NOW() + WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} AND mitra_id IS NULL 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 await sql` 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} ` // Mark other mitras' notifications as ignored await sql` 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 ` @@ -116,7 +153,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => { // Auto-skip payment for now: move to active and set expires_at const [activeSession] = await sql` UPDATE chat_sessions - SET status = 'active', + SET status = ${SessionStatus.ACTIVE}, expires_at = CASE WHEN duration_minutes IS NOT NULL THEN NOW() + (duration_minutes || ' minutes')::interval ELSE NULL @@ -127,7 +164,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => { // Record transaction 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` INSERT INTO customer_transactions (customer_id, session_id, type, amount) 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} ` - // Notify customer - await publish(`session:${sessionId}:status`, { - type: 'paired', + // Notify customer via WebSocket (FCM fallback) + await notifyCustomer(activeSession.customer_id, { + type: WsMessage.PAIRED, session_id: sessionId, mitra_display_name: mitra.display_name, - status: 'active', + status: SessionStatus.ACTIVE, }) // 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} ` for (const n of notifications) { - await publish(`mitra:${n.mitra_id}:requests`, { - type: 'chat_request_closed', + await notifyMitra(n.mitra_id, { + type: WsMessage.CHAT_REQUEST_CLOSED, session_id: sessionId, }) } @@ -173,7 +210,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => { export const declinePairingRequest = async (sessionId, mitraId) => { await sql` 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 ` } @@ -181,9 +218,9 @@ export const declinePairingRequest = async (sessionId, mitraId) => { export const cancelPairingRequest = async (sessionId, customerId) => { const [session] = await sql` UPDATE chat_sessions - SET status = 'cancelled' + SET status = ${SessionStatus.CANCELLED} WHERE id = ${sessionId} AND customer_id = ${customerId} - AND status IN ('searching', 'pending_acceptance') + AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}) RETURNING id, status ` @@ -203,7 +240,7 @@ export const cancelPairingRequest = async (sessionId, customerId) => { // Mark all notifications as ignored await sql` 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 ` @@ -212,8 +249,8 @@ export const cancelPairingRequest = async (sessionId, customerId) => { SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} ` for (const n of notifications) { - await publish(`mitra:${n.mitra_id}:requests`, { - type: 'chat_request_closed', + await notifyMitra(n.mitra_id, { + type: WsMessage.CHAT_REQUEST_CLOSED, session_id: sessionId, }) } @@ -224,8 +261,8 @@ export const cancelPairingRequest = async (sessionId, customerId) => { export const expirePairingRequest = async (sessionId) => { const [session] = await sql` UPDATE chat_sessions - SET status = 'expired' - WHERE id = ${sessionId} AND status = 'pending_acceptance' + SET status = ${SessionStatus.EXPIRED} + WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} RETURNING id, customer_id, status ` if (!session) return null @@ -235,13 +272,13 @@ export const expirePairingRequest = async (sessionId) => { // Mark all pending notifications as ignored await sql` 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 ` - // Notify customer - await publish(`session:${sessionId}:status`, { - type: 'expired', + // Notify customer via WebSocket (FCM fallback) + await notifyCustomer(session.customer_id, { + type: WsMessage.SESSION_EXPIRED, session_id: sessionId, }) @@ -250,8 +287,8 @@ export const expirePairingRequest = async (sessionId) => { SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} ` for (const n of notifications) { - await publish(`mitra:${n.mitra_id}:requests`, { - type: 'chat_request_closed', + await notifyMitra(n.mitra_id, { + type: WsMessage.CHAT_REQUEST_CLOSED, session_id: sessionId, }) } diff --git a/backend/src/services/pricing.service.js b/backend/src/services/pricing.service.js index 6b0b233..f0b9216 100644 --- a/backend/src/services/pricing.service.js +++ b/backend/src/services/pricing.service.js @@ -2,8 +2,9 @@ import { getDb } from '../db/client.js' const sql = getDb() -// Mock price tiers (will come from Control Center config later) -const PRICE_TIERS = [ +// Default tiers as fallback +const DEFAULT_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' }, @@ -11,10 +12,14 @@ const PRICE_TIERS = [ { 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) => { - return PRICE_TIERS.some( +export const isValidTier = async (durationMinutes, price) => { + const tiers = await getPriceTiers() + return tiers.some( (t) => t.duration_minutes === durationMinutes && t.price === price ) } @@ -41,7 +46,7 @@ export const isCustomerEligibleForFreeTrial = async (customerId) => { } export const getPricingForCustomer = async (customerId) => { - const tiers = getPriceTiers() + const tiers = await getPriceTiers() const freeTrialEligible = await isCustomerEligibleForFreeTrial(customerId) const freeTrial = await getFreeTrial() diff --git a/backend/src/services/session-timer.service.js b/backend/src/services/session-timer.service.js index 9e914f3..2799332 100644 --- a/backend/src/services/session-timer.service.js +++ b/backend/src/services/session-timer.service.js @@ -1,6 +1,7 @@ import { getDb } from '../db/client.js' import { publish } from '../plugins/valkey.js' import { sendToSessionParticipant } from '../plugins/websocket.js' +import { UserType, SessionStatus, WsMessage } from '../constants.js' const sql = getDb() @@ -62,34 +63,101 @@ export const extendSessionTimer = async (sessionId, additionalMinutes) => { } const onSessionWarning = (sessionId) => { - const data = { type: 'session_timer', remaining_seconds: 60, session_id: sessionId } - sendToSessionParticipant(sessionId, 'customer', data) - sendToSessionParticipant(sessionId, 'mitra', data) + const data = { type: WsMessage.SESSION_TIMER, remaining_seconds: 60, session_id: sessionId } + sendToSessionParticipant(sessionId, UserType.CUSTOMER, 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) => { clearSessionTimer(sessionId) - // Check session is still active + // Move session to closing status 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 - // Notify both parties - const data = { type: 'session_expired', session_id: sessionId } - sendToSessionParticipant(sessionId, 'customer', data) - sendToSessionParticipant(sessionId, 'mitra', data) + // Notify customer — sees extend/close dialog + const expiredData = { type: WsMessage.SESSION_EXPIRED, session_id: sessionId } + sendToSessionParticipant(sessionId, UserType.CUSTOMER, expiredData) + + // 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 - 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 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` 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) { startSessionTimer(session.id, session.expires_at) diff --git a/backend/src/services/session.service.js b/backend/src/services/session.service.js index 2f942f9..d08e61a 100644 --- a/backend/src/services/session.service.js +++ b/backend/src/services/session.service.js @@ -1,5 +1,6 @@ import { getDb } from '../db/client.js' import { publish } from '../plugins/valkey.js' +import { UserType, SessionStatus, WsMessage } from '../constants.js' const sql = getDb() @@ -11,7 +12,7 @@ export const getActiveSessionByCustomer = async (customerId) => { FROM chat_sessions cs LEFT JOIN mitras m ON m.id = cs.mitra_id WHERE cs.customer_id = ${customerId} - AND cs.status IN ('active', 'pending_payment', 'extending', 'closing') + AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}, ${SessionStatus.EXTENDING}, ${SessionStatus.CLOSING}) ORDER BY cs.created_at DESC LIMIT 1 ` return session @@ -25,17 +26,20 @@ export const getActiveSessionsByMitra = async (mitraId) => { FROM chat_sessions cs INNER JOIN customers c ON c.id = cs.customer_id WHERE cs.mitra_id = ${mitraId} - AND cs.status IN ('active', 'pending_payment', 'extending', 'closing') + AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}, ${SessionStatus.EXTENDING}, ${SessionStatus.CLOSING}) ORDER BY cs.created_at DESC ` 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` UPDATE chat_sessions - SET status = 'completed', ended_at = NOW(), ended_by = ${endedBy} - WHERE id = ${sessionId} AND status IN ('active', 'pending_payment') + SET status = ${SessionStatus.COMPLETED}, ended_at = NOW(), ended_by = ${endedBy} + 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 ` @@ -47,7 +51,7 @@ export const endSession = async (sessionId, endedBy) => { // Notify both parties await publish(`session:${sessionId}:status`, { - type: 'session_ended', + type: WsMessage.SESSION_ENDED, session_id: sessionId, ended_by: endedBy, }) @@ -59,7 +63,7 @@ export const rerouteSession = async (sessionId, newMitraId) => { // Get current session const [current] = await sql` SELECT id, customer_id, mitra_id, status FROM chat_sessions - WHERE id = ${sessionId} AND status IN ('active', 'pending_payment') + WHERE id = ${sessionId} AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}) ` if (!current) { @@ -94,7 +98,7 @@ export const rerouteSession = async (sessionId, newMitraId) => { // Notify customer about reroute await publish(`session:${sessionId}:status`, { - type: 'rerouted', + type: WsMessage.REROUTED, session_id: sessionId, mitra_display_name: newMitra.display_name, }) @@ -102,14 +106,14 @@ export const rerouteSession = async (sessionId, newMitraId) => { // Notify old mitra session removed if (oldMitraId) { await publish(`mitra:${oldMitraId}:requests`, { - type: 'session_rerouted', + type: WsMessage.SESSION_REROUTED, session_id: sessionId, }) } // Notify new mitra about new session await publish(`mitra:${newMitraId}:requests`, { - type: 'session_assigned', + type: WsMessage.SESSION_ASSIGNED, 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, cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes, m.display_name AS mitra_display_name, - (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'mitra' LIMIT 1) AS mitra_closure_message, - (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'customer' LIMIT 1) AS customer_closure_message + (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 = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message FROM chat_sessions cs LEFT JOIN mitras m ON m.id = cs.mitra_id WHERE cs.customer_id = ${customerId} - AND cs.status = 'completed' + AND cs.status = ${SessionStatus.COMPLETED} ORDER BY cs.ended_at DESC LIMIT ${limit} OFFSET ${offset} ` const [{ count }] = await sql` - SELECT COUNT(*) FROM chat_sessions WHERE customer_id = ${customerId} AND status = 'completed' + SELECT COUNT(*) FROM chat_sessions WHERE customer_id = ${customerId} AND status = ${SessionStatus.COMPLETED} ` 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, cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes, c.display_name AS customer_display_name, - (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'mitra' LIMIT 1) AS mitra_closure_message, - (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = 'customer' LIMIT 1) AS customer_closure_message + (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 = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message FROM chat_sessions cs INNER JOIN customers c ON c.id = cs.customer_id WHERE cs.mitra_id = ${mitraId} - AND cs.status = 'completed' + AND cs.status = ${SessionStatus.COMPLETED} ORDER BY cs.ended_at DESC LIMIT ${limit} OFFSET ${offset} ` const [{ count }] = await sql` - SELECT COUNT(*) FROM chat_sessions WHERE mitra_id = ${mitraId} AND status = 'completed' + SELECT COUNT(*) FROM chat_sessions WHERE mitra_id = ${mitraId} AND status = ${SessionStatus.COMPLETED} ` return { items, total: Number(count), page, limit } } diff --git a/client_app/android/app/build.gradle.kts b/client_app/android/app/build.gradle.kts index 80a3d92..5bcfc81 100644 --- a/client_app/android/app/build.gradle.kts +++ b/client_app/android/app/build.gradle.kts @@ -14,6 +14,7 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -42,6 +43,10 @@ android { } } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + flutter { source = "../.." } diff --git a/client_app/lib/core/api/api_client.dart b/client_app/lib/core/api/api_client.dart index 8894dde..8b03f4f 100644 --- a/client_app/lib/core/api/api_client.dart +++ b/client_app/lib/core/api/api_client.dart @@ -32,8 +32,4 @@ class ApiClient { final response = await _dio.get(path, queryParameters: queryParameters); return response.data as Map; } - - Future getStream(String path) async { - return _dio.get(path, options: Options(responseType: ResponseType.stream)); - } } diff --git a/client_app/lib/core/auth/auth_bloc.dart b/client_app/lib/core/auth/auth_bloc.dart index 0b3d701..aaf6424 100644 --- a/client_app/lib/core/auth/auth_bloc.dart +++ b/client_app/lib/core/auth/auth_bloc.dart @@ -76,7 +76,7 @@ class AuthBloc extends Bloc { final _auth = FirebaseAuth.instance; String? _pendingVerificationId; - AuthBloc({required this.apiClient}) : super(AuthInitial()) { + AuthBloc({required this.apiClient}) : super(AuthLoading()) { on(_onAppStarted); on(_onAnonymousLogin); on(_onGoogleLogin); diff --git a/client_app/lib/core/chat/chat_bloc.dart b/client_app/lib/core/chat/chat_bloc.dart index 14a029d..9b86b92 100644 --- a/client_app/lib/core/chat/chat_bloc.dart +++ b/client_app/lib/core/chat/chat_bloc.dart @@ -5,6 +5,7 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../api/api_client.dart'; +import '../constants.dart'; // Events abstract class ChatEvent extends Equatable { @@ -125,8 +126,8 @@ class ChatMessage { required this.id, required this.senderType, required this.content, - this.type = 'text', - this.status = 'sent', + this.type = MessageType.text, + this.status = MessageStatus.sent, required this.createdAt, }); @@ -164,6 +165,19 @@ class ChatBloc extends Bloc { emit(ChatConnecting()); try { + // Check session status before connecting + final sessionInfo = await apiClient.get('/api/shared/chat/${event.sessionId}/info'); + final sessionData = sessionInfo['data'] as Map?; + 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 final response = await apiClient.get( '/api/shared/chat/${event.sessionId}/messages', @@ -173,8 +187,8 @@ class ChatBloc extends Bloc { id: m['id'] as String, senderType: m['sender_type'] as String, content: m['content'] as String, - type: m['type'] as String? ?? 'text', - status: m['status'] as String? ?? 'sent', + type: m['type'] as String? ?? MessageType.text, + status: m['status'] as String? ?? MessageStatus.sent, createdAt: DateTime.parse(m['created_at'] as String), )).toList(); @@ -197,12 +211,15 @@ class ChatBloc extends Bloc { // Send auth message _channel!.sink.add(jsonEncode({ - 'type': 'auth', + 'type': WsMessage.auth, 'token': token, 'session_id': event.sessionId, })); - emit(ChatConnected(messages: messages)); + emit(ChatConnected( + messages: messages, + sessionClosing: isClosing, + )); } catch (e) { emit(ChatError('Gagal terhubung ke chat.')); } @@ -221,7 +238,7 @@ class ChatBloc extends Bloc { final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; final msg = ChatMessage( id: tempId, - senderType: 'customer', + senderType: UserType.customer, content: event.content, status: 'sending', createdAt: DateTime.now(), @@ -230,7 +247,7 @@ class ChatBloc extends Bloc { emit(current.copyWith(messages: [...current.messages, msg])); _channel!.sink.add(jsonEncode({ - 'type': 'message', + 'type': WsMessage.message, 'content': event.content, '_temp_id': tempId, })); @@ -238,13 +255,13 @@ class ChatBloc extends Bloc { void _onSendTyping(SendTyping event, Emitter emit) { if (_channel == null) return; - _channel!.sink.add(jsonEncode({'type': 'typing'})); + _channel!.sink.add(jsonEncode({'type': WsMessage.typing})); } void _onMarkDelivered(MarkMessagesDelivered event, Emitter emit) { if (_channel == null) return; _channel!.sink.add(jsonEncode({ - 'type': 'delivered', + 'type': WsMessage.delivered, 'message_ids': event.messageIds, })); } @@ -252,7 +269,7 @@ class ChatBloc extends Bloc { void _onMarkRead(MarkMessagesRead event, Emitter emit) { if (_channel == null) return; _channel!.sink.add(jsonEncode({ - 'type': 'read', + 'type': WsMessage.read, 'message_ids': event.messageIds, })); } @@ -264,17 +281,17 @@ class ChatBloc extends Bloc { final type = data['type'] as String?; switch (type) { - case 'auth_ok': + case WsMessage.authOk: // Already connected break; - case 'message': + case WsMessage.message: final msg = ChatMessage( id: data['message_id'] as String, senderType: data['sender_type'] as String, content: data['content'] as String, - type: data['message_type'] as String? ?? 'text', - status: 'sent', + type: data['message_type'] as String? ?? MessageType.text, + status: MessageStatus.sent, createdAt: DateTime.parse(data['created_at'] as String), ); emit(current.copyWith(messages: [...current.messages, msg])); @@ -282,7 +299,7 @@ class ChatBloc extends Bloc { add(MarkMessagesDelivered([msg.id])); break; - case 'message_ack': + case WsMessage.messageAck: final messageId = data['message_id'] as String; final status = data['status'] as String; final updatedMessages = current.messages.map((m) { @@ -292,7 +309,7 @@ class ChatBloc extends Bloc { return m; }).toList(); // 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) { final old = updatedMessages[idx]; updatedMessages[idx] = ChatMessage( @@ -307,7 +324,7 @@ class ChatBloc extends Bloc { emit(current.copyWith(messages: updatedMessages)); break; - case 'message_status': + case WsMessage.messageStatus: final messageIds = (data['message_ids'] as List).cast(); final status = data['status'] as String; final updatedMessages = current.messages.map((m) { @@ -319,7 +336,7 @@ class ChatBloc extends Bloc { emit(current.copyWith(messages: updatedMessages)); break; - case 'typing': + case WsMessage.typing: emit(current.copyWith(isOtherTyping: true)); _typingTimer?.cancel(); _typingTimer = Timer(const Duration(seconds: 3), () { @@ -329,36 +346,41 @@ class ChatBloc extends Bloc { }); break; - case 'session_timer': + case WsMessage.sessionTimer: final remaining = data['remaining_seconds'] as int?; emit(current.copyWith(remainingSeconds: remaining)); break; - case 'session_expired': + case WsMessage.sessionExpired: emit(current.copyWith(sessionExpired: true)); break; - case 'session_paused': + case WsMessage.sessionPaused: emit(current.copyWith(sessionPaused: true)); break; - case 'session_resumed': - emit(current.copyWith(sessionPaused: false, sessionExpired: false)); + case WsMessage.sessionResumed: + emit(current.copyWith(sessionPaused: false, sessionExpired: false, sessionClosing: false)); break; - case 'session_closing': + case WsMessage.sessionClosing: emit(current.copyWith(sessionClosing: true)); break; - case 'extension_response': - emit(current.copyWith(extensionResponse: data)); + case WsMessage.extensionResponse: + final accepted = data['accepted'] as bool? ?? false; + emit(current.copyWith( + extensionResponse: data, + sessionPaused: accepted ? false : current.sessionPaused, + sessionExpired: accepted ? false : current.sessionExpired, + )); break; - case 'session_completed': + case WsMessage.sessionCompleted: _cleanup(); break; - case 'error': + case WsMessage.error: // Keep connected but show error break; } diff --git a/client_app/lib/core/chat/session_closure_bloc.dart b/client_app/lib/core/chat/session_closure_bloc.dart index d259308..8bbf9a0 100644 --- a/client_app/lib/core/chat/session_closure_bloc.dart +++ b/client_app/lib/core/chat/session_closure_bloc.dart @@ -18,6 +18,7 @@ class RequestExtension extends SessionClosureEvent { } class DeclineExtension extends SessionClosureEvent {} +class ResetClosure extends SessionClosureEvent {} class SubmitGoodbye extends SessionClosureEvent { final String sessionId; @@ -56,6 +57,7 @@ class SessionClosureBloc extends Bloc SessionClosureBloc({required this.apiClient}) : super(ClosureInitial()) { on(_onRequestExtension); on(_onDeclineExtension); + on(_onReset); on(_onSubmitGoodbye); } @@ -76,6 +78,10 @@ class SessionClosureBloc extends Bloc emit(ClosureShowGoodbye()); } + void _onReset(ResetClosure event, Emitter emit) { + emit(ClosureInitial()); + } + Future _onSubmitGoodbye(SubmitGoodbye event, Emitter emit) async { emit(ClosureSubmitting()); try { diff --git a/client_app/lib/core/constants.dart b/client_app/lib/core/constants.dart new file mode 100644 index 0000000..888564c --- /dev/null +++ b/client_app/lib/core/constants.dart @@ -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._(); +} diff --git a/client_app/lib/core/notifications/notification_service.dart b/client_app/lib/core/notifications/notification_service.dart new file mode 100644 index 0000000..9cb0e99 --- /dev/null +++ b/client_app/lib/core/notifications/notification_service.dart @@ -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 initialize(GoRouter router) async { + _router = router; + + // Create Android notification channel + await _localNotifications + .resolvePlatformSpecificImplementation() + ?.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; + _navigateFromMessage(data); + } catch (_) {} + } + + static void _onMessageOpenedApp(RemoteMessage message) { + _navigateFromMessage(message.data); + } + + static void _navigateFromMessage(Map 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'); + } + } +} diff --git a/client_app/lib/core/pairing/pairing_bloc.dart b/client_app/lib/core/pairing/pairing_bloc.dart index bc0bad0..bfce64d 100644 --- a/client_app/lib/core/pairing/pairing_bloc.dart +++ b/client_app/lib/core/pairing/pairing_bloc.dart @@ -2,8 +2,11 @@ import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:equatable/equatable.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; import '../api/api_client.dart'; +import '../constants.dart'; // Events abstract class PairingEvent extends Equatable { @@ -32,6 +35,7 @@ class _PairingStatusUpdate extends PairingEvent { } class _PairingTimeout extends PairingEvent {} +class _ConnectionError extends PairingEvent {} // States abstract class PairingState extends Equatable { @@ -77,7 +81,8 @@ class PairingError extends PairingState { class PairingBloc extends Bloc { final ApiClient apiClient; Timer? _timeoutTimer; - StreamSubscription? _sseSubscription; + WebSocketChannel? _channel; + StreamSubscription? _wsSubscription; PairingBloc({required this.apiClient}) : super(PairingInitial()) { on(_onRequestPairing); @@ -85,6 +90,7 @@ class PairingBloc extends Bloc { on(_onCancelPairing); on<_PairingStatusUpdate>(_onStatusUpdate); on<_PairingTimeout>(_onTimeout); + on<_ConnectionError>(_onConnectionError); } Future _onRequestPairing(RequestPairing event, Emitter emit) async { @@ -107,6 +113,9 @@ class PairingBloc extends Bloc { emit(PairingInitial()); } try { + // Connect to WebSocket first to listen for pairing status + await _connectWebSocket(); + final response = await apiClient.post('/api/client/chat/request', data: body); final data = response['data'] as Map; final sessionId = data['id'] as String; @@ -116,9 +125,8 @@ class PairingBloc extends Bloc { _timeoutTimer = Timer(const Duration(seconds: 60), () { add(_PairingTimeout()); }); - - _listenToSSE(sessionId); } on DioException catch (e) { + _cleanup(); final code = e.response?.data?['error']?['code']; if (code == 'NO_MITRA_AVAILABLE') { emit(PairingNoBestie()); @@ -132,26 +140,45 @@ class PairingBloc extends Bloc { } } - void _listenToSSE(String sessionId) { - apiClient.getStream('/api/client/chat/request/$sessionId/status').then((response) { - final stream = response.data.stream as Stream>; - _sseSubscription = stream - .transform(utf8.decoder) - .transform(const LineSplitter()) - .where((line) => line.startsWith('data: ')) - .map((line) => jsonDecode(line.substring(6)) as Map) - .listen( - (data) => add(_PairingStatusUpdate(data)), - onError: (_) {}, - ); - }).catchError((_) {}); + Future _connectWebSocket() async { + _closeWebSocket(); + final user = FirebaseAuth.instance.currentUser; + if (user == null) return; + + final token = await user.getIdToken(); + final wsUrl = ApiClient.baseUrl + .replaceFirst('https://', 'wss://') + .replaceFirst('http://', 'ws://'); + + _channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws')); + + _wsSubscription = _channel!.stream.listen( + (raw) { + final data = jsonDecode(raw as String) as Map; + 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 _onConnectionError(_ConnectionError event, Emitter emit) async { + // WebSocket disconnected during pairing — stay in current state, + // FCM will still deliver notifications } Future _onStatusUpdate(_PairingStatusUpdate event, Emitter emit) async { final data = event.data; final type = data['type'] as String?; - if (type == 'paired') { + if (type == WsMessage.paired) { _cleanup(); final mitraName = data['mitra_display_name'] as String? ?? 'Bestie'; final sessionId = data['session_id'] as String; @@ -160,7 +187,7 @@ class PairingBloc extends Bloc { // Brief delay then transition to active await Future.delayed(const Duration(seconds: 2)); emit(PairingActive(sessionId: sessionId, mitraName: mitraName)); - } else if (type == 'expired') { + } else if (type == SessionStatus.expired) { _cleanup(); emit(PairingNoBestie()); } @@ -182,11 +209,17 @@ class PairingBloc extends Bloc { emit(PairingNoBestie()); } + void _closeWebSocket() { + _wsSubscription?.cancel(); + _wsSubscription = null; + _channel?.sink.close(); + _channel = null; + } + void _cleanup() { _timeoutTimer?.cancel(); _timeoutTimer = null; - _sseSubscription?.cancel(); - _sseSubscription = null; + _closeWebSocket(); } @override diff --git a/client_app/lib/features/chat/screens/chat_screen.dart b/client_app/lib/features/chat/screens/chat_screen.dart index ca62776..39723a6 100644 --- a/client_app/lib/features/chat/screens/chat_screen.dart +++ b/client_app/lib/features/chat/screens/chat_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../../../core/chat/chat_bloc.dart'; import '../../../core/chat/session_closure_bloc.dart'; +import '../../../core/constants.dart'; import '../widgets/pricing_bottom_sheet.dart'; class ChatScreen extends StatefulWidget { @@ -21,8 +22,15 @@ class _ChatScreenState extends State { final _scrollController = ScrollController(); Timer? _typingThrottle; + @override + void initState() { + super.initState(); + context.read().add(ConnectChat(widget.sessionId)); + } + @override void dispose() { + context.read().add(DisconnectChat()); _messageController.dispose(); _scrollController.dispose(); _typingThrottle?.cancel(); @@ -64,19 +72,31 @@ class _ChatScreenState extends State { if (prev is ChatConnected && curr is ChatConnected) { return prev.sessionExpired != curr.sessionExpired || prev.sessionClosing != curr.sessionClosing || + prev.sessionPaused != curr.sessionPaused || prev.messages.length != curr.messages.length; } return true; }, listener: (context, state) { if (state is ChatConnected) { - if (state.sessionClosing) { - context.read().add(DeclineExtension()); + // Only trigger goodbye if closing AND not expired (expired shows extend dialog first) + if (state.sessionClosing && !state.sessionExpired) { + final closureState = context.read().state; + if (closureState is ClosureInitial) { + context.read().add(DeclineExtension()); + } + } + // Extension accepted — reset closure bloc to go back to chat + if (!state.sessionPaused && !state.sessionExpired && !state.sessionClosing) { + final closureState = context.read().state; + if (closureState is! ClosureInitial) { + context.read().add(ResetClosure()); + } } _scrollToBottom(); // Auto-mark received messages as read 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) .toList(); if (unread.isNotEmpty) { @@ -138,17 +158,17 @@ class _ChatScreenState extends State { } Widget _buildChatBody(BuildContext context, ChatConnected state) { - // Show session expired dialog - if (state.sessionExpired) { - return _buildExpiredView(context); - } - - // Show goodbye input + // Show goodbye input (takes priority — user already decided to close) final closureState = context.watch().state; if (closureState is ClosureShowGoodbye || closureState is ClosureSubmitting) { return _buildGoodbyeView(context, closureState); } + // Show session expired dialog (extend or close?) + if (state.sessionExpired) { + return _buildExpiredView(context); + } + if (state.sessionPaused) { return _buildPausedView(); } @@ -162,7 +182,7 @@ class _ChatScreenState extends State { itemCount: state.messages.length, itemBuilder: (context, index) { final msg = state.messages[index]; - final isMe = msg.senderType == 'customer'; + final isMe = msg.senderType == UserType.customer; return _buildMessageBubble(msg, isMe); }, ), @@ -219,11 +239,11 @@ class _ChatScreenState extends State { switch (status) { case 'sending': 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); - case 'delivered': + case MessageStatus.delivered: 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); default: return const SizedBox.shrink(); @@ -264,25 +284,48 @@ class _ChatScreenState extends State { return Center( child: Padding( padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.timer_off, size: 64, color: Colors.orange), - const SizedBox(height: 16), - const Text('Waktu sesi habis', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - const Text('Apakah kamu ingin memperpanjang sesi?', textAlign: TextAlign.center), - const SizedBox(height: 24), - ElevatedButton( - onPressed: () => PricingBottomSheet.show(context), - child: const Text('Perpanjang Sesi'), - ), - const SizedBox(height: 12), - TextButton( - onPressed: () => context.read().add(DeclineExtension()), - child: const Text('Tidak, akhiri sesi'), - ), - ], + child: TweenAnimationBuilder( + tween: IntTween(begin: 300, end: 0), + duration: const Duration(seconds: 300), + builder: (context, remaining, _) { + if (remaining <= 0) { + // Auto-decline when countdown reaches 0 + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().add(DeclineExtension()); + }); + } + final minutes = remaining ~/ 60; + final seconds = remaining % 60; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.timer_off, size: 64, color: Colors.orange), + const SizedBox(height: 16), + const Text('Waktu sesi habis', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('Apakah kamu ingin memperpanjang sesi?', textAlign: TextAlign.center), + const SizedBox(height: 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().add(DeclineExtension()), + child: const Text('Tidak, akhiri sesi'), + ), + ], + ); + }, ), ), ); @@ -290,13 +333,12 @@ class _ChatScreenState extends State { Widget _buildGoodbyeView(BuildContext context, SessionClosureState closureState) { final controller = TextEditingController(); - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.waving_hand, size: 64, color: Colors.amber), + return SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + const SizedBox(height: 48), + const Icon(Icons.waving_hand, size: 64, color: Colors.amber), const SizedBox(height: 16), const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 8), @@ -328,7 +370,6 @@ class _ChatScreenState extends State { ), ], ), - ), ); } diff --git a/client_app/lib/features/chat/screens/chat_transcript_screen.dart b/client_app/lib/features/chat/screens/chat_transcript_screen.dart index 9cc380d..5cd1c48 100644 --- a/client_app/lib/features/chat/screens/chat_transcript_screen.dart +++ b/client_app/lib/features/chat/screens/chat_transcript_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../core/api/api_client.dart'; +import '../../../core/constants.dart'; class ChatTranscriptScreen extends StatefulWidget { final String sessionId; @@ -47,7 +48,7 @@ class _ChatTranscriptScreenState extends State { padding: const EdgeInsets.all(16), children: [ ..._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(); return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, @@ -79,7 +80,7 @@ class _ChatTranscriptScreenState extends State { const SizedBox(height: 8), ..._closures.map((c) => Card( 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), ), )), diff --git a/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart index 952440c..6bc9062 100644 --- a/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart +++ b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart @@ -2,18 +2,45 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../core/api/api_client.dart'; import '../../../core/chat/chat_opening_bloc.dart'; +import '../../../core/chat/session_closure_bloc.dart'; import '../../../core/pairing/pairing_bloc.dart'; 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 show(BuildContext context) { return showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => BlocProvider( create: (ctx) => ChatOpeningBloc(apiClient: ctx.read())..add(LoadPricing()), - child: const PricingBottomSheet(), + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: context.read()), + ], + child: const PricingBottomSheet(), + ), + ), + ); + } + + /// Show for session extension (from chat screen) + static Future showForExtension(BuildContext context, {required String sessionId}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => BlocProvider( + create: (ctx) => ChatOpeningBloc(apiClient: ctx.read())..add(LoadPricing()), + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: context.read()), + ], + child: PricingBottomSheet(extensionSessionId: sessionId), + ), ), ); } @@ -30,6 +57,8 @@ class PricingBottomSheet extends StatelessWidget { @override Widget build(BuildContext context) { + final isExtension = extensionSessionId != null; + return BlocBuilder( builder: (context, state) { if (state is PricingLoading || state is PricingInitial) { @@ -58,13 +87,13 @@ class PricingBottomSheet extends StatelessWidget { child: ListView( controller: scrollController, children: [ - const Text( - 'Pilih Durasi Curhat', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + Text( + isExtension ? 'Perpanjang Durasi' : 'Pilih Durasi Curhat', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), const SizedBox(height: 16), - if (state.freeTrialEligible) ...[ + if (!isExtension && state.freeTrialEligible) ...[ Card( color: Colors.green.shade50, child: ListTile( @@ -89,11 +118,20 @@ class PricingBottomSheet extends StatelessWidget { ), onTap: () { Navigator.of(context).pop(); - _startPairing( - context, - durationMinutes: tier.durationMinutes, - price: tier.price, - ); + if (isExtension) { + _requestExtension( + context, + 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, )); } + + void _requestExtension(BuildContext context, {required String sessionId, required int durationMinutes, required int price}) { + context.read().add(RequestExtension( + sessionId: sessionId, + durationMinutes: durationMinutes, + price: price, + )); + } } diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart index 37d2268..0fdfea7 100644 --- a/client_app/lib/features/home/home_screen.dart +++ b/client_app/lib/features/home/home_screen.dart @@ -2,12 +2,64 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../../core/auth/auth_bloc.dart'; +import '../../core/api/api_client.dart'; import '../../core/pairing/pairing_bloc.dart'; import '../chat/widgets/pricing_bottom_sheet.dart'; -class HomeScreen extends StatelessWidget { +class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State with WidgetsBindingObserver { + Map? _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 _checkActiveSession() async { + try { + final apiClient = context.read(); + final response = await apiClient.get('/api/client/chat/session/active'); + final data = response['data']; + if (mounted) { + setState(() { + _activeSession = data is Map ? data : null; + _loadingSession = false; + }); + } + } catch (_) { + if (mounted) setState(() => _loadingSession = false); + } + } + @override Widget build(BuildContext context) { return BlocListener( @@ -51,14 +103,28 @@ class HomeScreen extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)), - const SizedBox(height: 48), - ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16), + const SizedBox(height: 32), + if (_loadingSession) + const CircularProgressIndicator() + 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 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), + ], + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/splash/splash_screen.dart b/client_app/lib/features/splash/splash_screen.dart new file mode 100644 index 0000000..335021f --- /dev/null +++ b/client_app/lib/features/splash/splash_screen.dart @@ -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(), + ], + ), + ), + ); + } +} diff --git a/client_app/lib/main.dart b/client_app/lib/main.dart index 2042372..dbecfdd 100644 --- a/client_app/lib/main.dart +++ b/client_app/lib/main.dart @@ -8,6 +8,7 @@ import 'core/auth/auth_bloc.dart'; import 'core/chat/chat_bloc.dart'; import 'core/chat/session_closure_bloc.dart'; import 'core/pairing/pairing_bloc.dart'; +import 'core/notifications/notification_service.dart'; import 'firebase_options.dart'; import 'router.dart'; @@ -39,6 +40,7 @@ class _AppState extends State { super.initState(); _authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted()); _router = buildRouter(_authBloc); + NotificationService.initialize(_router); _registerFcmToken(); } diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index f9234d3..2f30d92 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -7,6 +7,7 @@ import 'features/auth/screens/display_name_screen.dart'; import 'features/auth/screens/register_screen.dart'; import 'features/auth/screens/otp_screen.dart'; import 'features/auth/screens/force_register_screen.dart'; +import 'features/splash/splash_screen.dart'; import 'features/home/home_screen.dart'; import 'features/chat/screens/searching_screen.dart'; import 'features/chat/screens/bestie_found_screen.dart'; @@ -32,24 +33,27 @@ class _BlocRefreshNotifier extends ChangeNotifier { GoRouter buildRouter(AuthBloc authBloc) { return GoRouter( - initialLocation: '/welcome', + initialLocation: '/splash', refreshListenable: _BlocRefreshNotifier(authBloc), redirect: (context, state) { final authState = authBloc.state; + final isSplash = state.matchedLocation == '/splash'; final isAuthRoute = state.matchedLocation.startsWith('/auth') || state.matchedLocation == '/welcome'; - // Don't redirect while loading — stay on current screen - if (authState is AuthLoading) return null; + // Show splash while loading + if (authState is AuthLoading) return isSplash ? null : '/splash'; 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 (!isAuthRoute) return '/welcome'; + if (!isAuthRoute && !isSplash) return '/welcome'; + if (isSplash) return '/welcome'; return null; }, routes: [ + GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()), GoRoute(path: '/welcome', builder: (_, __) => const WelcomeScreen()), GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()), 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/session/:sessionId', builder: (context, state) { - final extra = state.extra as Map?; + final extra = state.extra; + final mitraName = extra is String + ? extra + : (extra is Map ? extra['mitraName'] as String? : null); return ChatScreen( sessionId: state.pathParameters['sessionId']!, - mitraName: extra?['mitraName'] as String? ?? 'Bestie', + mitraName: mitraName ?? 'Bestie', ); }), GoRoute(path: '/chat/history', builder: (_, __) => const ChatHistoryScreen()), diff --git a/client_app/macos/Flutter/GeneratedPluginRegistrant.swift b/client_app/macos/Flutter/GeneratedPluginRegistrant.swift index 358099a..e483bf5 100644 --- a/client_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import firebase_auth import firebase_core import firebase_messaging +import flutter_local_notifications import google_sign_in_ios import shared_preferences_foundation import sign_in_with_apple @@ -16,6 +17,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) diff --git a/client_app/pubspec.lock b/client_app/pubspec.lock index 5c3ce14..27ce694 100644 --- a/client_app/pubspec.lock +++ b/client_app/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.35" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -65,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" dio: dependency: "direct main" description: @@ -206,6 +222,38 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: flutter @@ -400,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -557,6 +613,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + timezone: + dependency: transitive + description: + name: timezone + sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" + url: "https://pub.dev" + source: hosted + version: "0.11.0" typed_data: dependency: transitive description: @@ -605,6 +669,14 @@ packages: 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: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.1" diff --git a/client_app/pubspec.yaml b/client_app/pubspec.yaml index 7547c44..b0c1704 100644 --- a/client_app/pubspec.yaml +++ b/client_app/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: # Navigation go_router: ^13.2.1 + flutter_local_notifications: ^21.0.0 dev_dependencies: flutter_test: diff --git a/client_app/windows/flutter/generated_plugins.cmake b/client_app/windows/flutter/generated_plugins.cmake index 29944d5..ba5c35b 100644 --- a/client_app/windows/flutter/generated_plugins.cmake +++ b/client_app/windows/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/mitra_app/android/app/build.gradle.kts b/mitra_app/android/app/build.gradle.kts index 3835ee3..9c96d33 100644 --- a/mitra_app/android/app/build.gradle.kts +++ b/mitra_app/android/app/build.gradle.kts @@ -11,6 +11,7 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -39,6 +40,10 @@ android { } } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + flutter { source = "../.." } diff --git a/mitra_app/lib/core/api/api_client.dart b/mitra_app/lib/core/api/api_client.dart index b895d6c..d91fb0c 100644 --- a/mitra_app/lib/core/api/api_client.dart +++ b/mitra_app/lib/core/api/api_client.dart @@ -32,8 +32,4 @@ class ApiClient { final response = await _dio.post(path, data: data); return response.data as Map; } - - Future getStream(String path) async { - return _dio.get(path, options: Options(responseType: ResponseType.stream)); - } } diff --git a/mitra_app/lib/core/auth/auth_bloc.dart b/mitra_app/lib/core/auth/auth_bloc.dart index dbcb481..b7911ff 100644 --- a/mitra_app/lib/core/auth/auth_bloc.dart +++ b/mitra_app/lib/core/auth/auth_bloc.dart @@ -55,7 +55,7 @@ class AuthBloc extends Bloc { final _auth = FirebaseAuth.instance; ConfirmationResult? _webConfirmationResult; - AuthBloc({required this.apiClient}) : super(AuthInitial()) { + AuthBloc({required this.apiClient}) : super(AuthLoading()) { on(_onAppStarted); on(_onPhoneOtpRequested); on(_onOtpVerified); diff --git a/mitra_app/lib/core/chat/chat_request_bloc.dart b/mitra_app/lib/core/chat/chat_request_bloc.dart index 86a1671..e293eb7 100644 --- a/mitra_app/lib/core/chat/chat_request_bloc.dart +++ b/mitra_app/lib/core/chat/chat_request_bloc.dart @@ -2,8 +2,11 @@ import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:equatable/equatable.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; import '../api/api_client.dart'; +import '../constants.dart'; // Events abstract class ChatRequestEvent extends Equatable { @@ -21,6 +24,8 @@ class _RequestReceived extends ChatRequestEvent { List get props => [data]; } +class _ConnectionError extends ChatRequestEvent {} + class AcceptRequest extends ChatRequestEvent { final String sessionId; AcceptRequest(this.sessionId); @@ -70,49 +75,76 @@ class ChatRequestError extends ChatRequestState { // Bloc class ChatRequestBloc extends Bloc { final ApiClient apiClient; - StreamSubscription? _sseSubscription; + WebSocketChannel? _channel; + StreamSubscription? _wsSubscription; ChatRequestBloc({required this.apiClient}) : super(ChatRequestIdle()) { on(_onStartListening); on(_onStopListening); on<_RequestReceived>(_onRequestReceived); + on<_ConnectionError>(_onConnectionError); on(_onAcceptRequest); on(_onDeclineRequest); } Future _onStartListening(StartListening event, Emitter emit) async { - _stopSSE(); + _closeWebSocket(); emit(ChatRequestListening()); - _listenToSSE(); + await _connectWebSocket(); } Future _onStopListening(StopListening event, Emitter emit) async { - _stopSSE(); + _closeWebSocket(); emit(ChatRequestIdle()); } - void _listenToSSE() { - apiClient.getStream('/api/mitra/chat-requests/incoming').then((response) { - final stream = response.data.stream as Stream>; - _sseSubscription = stream - .transform(utf8.decoder) - .transform(const LineSplitter()) - .where((line) => line.startsWith('data: ')) - .map((line) => jsonDecode(line.substring(6)) as Map) - .listen( - (data) => add(_RequestReceived(data)), - onError: (_) {}, - ); - }).catchError((_) {}); + Future _connectWebSocket() async { + try { + final user = FirebaseAuth.instance.currentUser; + if (user == null) return; + + final token = await user.getIdToken(); + final wsUrl = ApiClient.baseUrl + .replaceFirst('https://', 'wss://') + .replaceFirst('http://', 'ws://'); + + _channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws')); + + _wsSubscription = _channel!.stream.listen( + (raw) { + final data = jsonDecode(raw as String) as Map; + 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 _onConnectionError(_ConnectionError event, Emitter emit) async { + _closeWebSocket(); + // Stay in listening state — FCM will still deliver notifications + if (state is! ChatRequestIdle) { + emit(ChatRequestListening()); + } } Future _onRequestReceived(_RequestReceived event, Emitter emit) async { final data = event.data; final type = data['type'] as String?; - if (type == 'chat_request') { + if (type == WsMessage.chatRequest) { 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 if (state is ChatRequestIncoming) { emit(ChatRequestListening()); @@ -148,14 +180,16 @@ class ChatRequestBloc extends Bloc { emit(ChatRequestListening()); } - void _stopSSE() { - _sseSubscription?.cancel(); - _sseSubscription = null; + void _closeWebSocket() { + _wsSubscription?.cancel(); + _wsSubscription = null; + _channel?.sink.close(); + _channel = null; } @override Future close() { - _stopSSE(); + _closeWebSocket(); return super.close(); } } diff --git a/mitra_app/lib/core/chat/extension_bloc.dart b/mitra_app/lib/core/chat/extension_bloc.dart index ec53435..e088e6f 100644 --- a/mitra_app/lib/core/chat/extension_bloc.dart +++ b/mitra_app/lib/core/chat/extension_bloc.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../api/api_client.dart'; @@ -65,8 +66,14 @@ class ExtensionBloc extends Bloc { } else { emit(ExtensionIdle()); } - } catch (e) { - emit(ExtensionError('Gagal merespon perpanjangan.')); + } on DioException catch (e) { + 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.')); + } } } diff --git a/mitra_app/lib/core/chat/mitra_chat_bloc.dart b/mitra_app/lib/core/chat/mitra_chat_bloc.dart index 89cd69e..23f62c9 100644 --- a/mitra_app/lib/core/chat/mitra_chat_bloc.dart +++ b/mitra_app/lib/core/chat/mitra_chat_bloc.dart @@ -5,6 +5,7 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../api/api_client.dart'; +import '../constants.dart'; // Events abstract class MitraChatEvent extends Equatable { @@ -86,6 +87,7 @@ class ChatConnected extends MitraChatState { bool? sessionExpired, bool? sessionClosing, Map? extensionRequest, + bool clearExtensionRequest = false, }) { return ChatConnected( messages: messages ?? this.messages, @@ -93,7 +95,7 @@ class ChatConnected extends MitraChatState { remainingSeconds: remainingSeconds ?? this.remainingSeconds, sessionExpired: sessionExpired ?? this.sessionExpired, 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.senderType, required this.content, - this.type = 'text', - this.status = 'sent', + this.type = MessageType.text, + this.status = MessageStatus.sent, required this.createdAt, }); @@ -160,14 +162,27 @@ class MitraChatBloc extends Bloc { emit(ChatConnecting()); try { + // Check session status before connecting + final sessionInfo = await apiClient.get('/api/shared/chat/${event.sessionId}/info'); + final sessionData = sessionInfo['data'] as Map?; + 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 messagesData = response['data'] as List; final messages = messagesData.map((m) => ChatMessage( id: m['id'] as String, senderType: m['sender_type'] as String, content: m['content'] as String, - type: m['type'] as String? ?? 'text', - status: m['status'] as String? ?? 'sent', + type: m['type'] as String? ?? MessageType.text, + status: m['status'] as String? ?? MessageStatus.sent, createdAt: DateTime.parse(m['created_at'] as String), )).toList(); @@ -188,12 +203,15 @@ class MitraChatBloc extends Bloc { ); _channel!.sink.add(jsonEncode({ - 'type': 'auth', + 'type': WsMessage.auth, 'token': token, 'session_id': event.sessionId, })); - emit(ChatConnected(messages: messages)); + emit(ChatConnected( + messages: messages, + sessionClosing: isClosing, + )); } catch (e) { emit(ChatError('Gagal terhubung ke chat.')); } @@ -211,7 +229,7 @@ class MitraChatBloc extends Bloc { final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; final msg = ChatMessage( id: tempId, - senderType: 'mitra', + senderType: UserType.mitra, content: event.content, status: 'sending', createdAt: DateTime.now(), @@ -220,7 +238,7 @@ class MitraChatBloc extends Bloc { emit(current.copyWith(messages: [...current.messages, msg])); _channel!.sink.add(jsonEncode({ - 'type': 'message', + 'type': WsMessage.message, 'content': event.content, '_temp_id': tempId, })); @@ -228,17 +246,17 @@ class MitraChatBloc extends Bloc { void _onSendTyping(SendTyping event, Emitter emit) { if (_channel == null) return; - _channel!.sink.add(jsonEncode({'type': 'typing'})); + _channel!.sink.add(jsonEncode({'type': WsMessage.typing})); } void _onMarkDelivered(MarkMessagesDelivered event, Emitter emit) { 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 emit) { 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 emit) { @@ -248,30 +266,30 @@ class MitraChatBloc extends Bloc { final type = data['type'] as String?; switch (type) { - case 'auth_ok': + case WsMessage.authOk: break; - case 'message': + case WsMessage.message: final msg = ChatMessage( id: data['message_id'] as String, senderType: data['sender_type'] as String, content: data['content'] as String, - type: data['message_type'] as String? ?? 'text', - status: 'sent', + type: data['message_type'] as String? ?? MessageType.text, + status: MessageStatus.sent, createdAt: DateTime.parse(data['created_at'] as String), ); emit(current.copyWith(messages: [...current.messages, msg])); add(MarkMessagesDelivered([msg.id])); break; - case 'message_ack': + case WsMessage.messageAck: final messageId = data['message_id'] as String; final status = data['status'] as String; final updatedMessages = current.messages.map((m) { if (m.status == 'sending') return m.copyWith(status: status); return m; }).toList(); - final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == 'mitra'); + final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.mitra); if (idx >= 0) { final old = updatedMessages[idx]; updatedMessages[idx] = ChatMessage( @@ -286,7 +304,7 @@ class MitraChatBloc extends Bloc { emit(current.copyWith(messages: updatedMessages)); break; - case 'message_status': + case WsMessage.messageStatus: final messageIds = (data['message_ids'] as List).cast(); final status = data['status'] as String; final updatedMessages = current.messages.map((m) { @@ -296,7 +314,7 @@ class MitraChatBloc extends Bloc { emit(current.copyWith(messages: updatedMessages)); break; - case 'typing': + case WsMessage.typing: emit(current.copyWith(isOtherTyping: true)); _typingTimer?.cancel(); _typingTimer = Timer(const Duration(seconds: 3), () { @@ -306,27 +324,27 @@ class MitraChatBloc extends Bloc { }); break; - case 'session_timer': + case WsMessage.sessionTimer: emit(current.copyWith(remainingSeconds: data['remaining_seconds'] as int?)); break; - case 'session_expired': + case WsMessage.sessionExpired: emit(current.copyWith(sessionExpired: true)); break; - case 'extension_request': + case WsMessage.extensionRequest: emit(current.copyWith(extensionRequest: data)); break; - case 'session_resumed': - emit(current.copyWith(sessionExpired: false, extensionRequest: null)); + case WsMessage.sessionResumed: + emit(current.copyWith(sessionExpired: false, sessionClosing: false, clearExtensionRequest: true)); break; - case 'session_closing': - emit(current.copyWith(sessionClosing: true)); + case WsMessage.sessionClosing: + emit(current.copyWith(sessionClosing: true, clearExtensionRequest: true)); break; - case 'session_completed': + case WsMessage.sessionCompleted: _cleanup(); break; } diff --git a/mitra_app/lib/core/constants.dart b/mitra_app/lib/core/constants.dart new file mode 100644 index 0000000..888564c --- /dev/null +++ b/mitra_app/lib/core/constants.dart @@ -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._(); +} diff --git a/mitra_app/lib/core/notifications/notification_service.dart b/mitra_app/lib/core/notifications/notification_service.dart new file mode 100644 index 0000000..9cb0e99 --- /dev/null +++ b/mitra_app/lib/core/notifications/notification_service.dart @@ -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 initialize(GoRouter router) async { + _router = router; + + // Create Android notification channel + await _localNotifications + .resolvePlatformSpecificImplementation() + ?.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; + _navigateFromMessage(data); + } catch (_) {} + } + + static void _onMessageOpenedApp(RemoteMessage message) { + _navigateFromMessage(message.data); + } + + static void _navigateFromMessage(Map 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'); + } + } +} diff --git a/mitra_app/lib/features/chat/screens/active_sessions_screen.dart b/mitra_app/lib/features/chat/screens/active_sessions_screen.dart index 471e124..6b7e3d8 100644 --- a/mitra_app/lib/features/chat/screens/active_sessions_screen.dart +++ b/mitra_app/lib/features/chat/screens/active_sessions_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import '../../../core/api/api_client.dart'; class ActiveSessionsScreen extends StatefulWidget { @@ -72,14 +73,19 @@ class _ActiveSessionsScreenState extends State { itemCount: _sessions.length, itemBuilder: (context, index) { final session = _sessions[index]; + final customerName = session['customer_display_name'] as String? ?? 'Customer'; return ListTile( - leading: const Icon(Icons.person), - title: Text(session['customer_display_name'] as String? ?? 'Customer'), + leading: const Icon(Icons.chat), + title: Text(customerName), subtitle: Text('Status: ${session['status']}'), trailing: TextButton( onPressed: () => _endSession(session['id'] as String), child: const Text('Akhiri', style: TextStyle(color: Colors.red)), ), + onTap: () => context.push( + '/chat/session/${session['id']}', + extra: {'customerName': customerName}, + ), ); }, ), diff --git a/mitra_app/lib/features/chat/screens/chat_transcript_screen.dart b/mitra_app/lib/features/chat/screens/chat_transcript_screen.dart index 1bca658..337e96a 100644 --- a/mitra_app/lib/features/chat/screens/chat_transcript_screen.dart +++ b/mitra_app/lib/features/chat/screens/chat_transcript_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../core/api/api_client.dart'; +import '../../../core/constants.dart'; class MitraChatTranscriptScreen extends StatefulWidget { final String sessionId; @@ -47,7 +48,7 @@ class _MitraChatTranscriptScreenState extends State { padding: const EdgeInsets.all(16), children: [ ..._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(); return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, @@ -79,7 +80,7 @@ class _MitraChatTranscriptScreenState extends State { const SizedBox(height: 8), ..._closures.map((c) => Card( 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), ), )), diff --git a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart index cc7718e..1e9d99e 100644 --- a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart +++ b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../../../core/chat/mitra_chat_bloc.dart'; import '../../../core/chat/extension_bloc.dart'; +import '../../../core/constants.dart'; class MitraChatScreen extends StatefulWidget { final String sessionId; @@ -20,8 +21,15 @@ class _MitraChatScreenState extends State { final _scrollController = ScrollController(); Timer? _typingThrottle; + @override + void initState() { + super.initState(); + context.read().add(ConnectChat(widget.sessionId)); + } + @override void dispose() { + context.read().add(DisconnectChat()); _messageController.dispose(); _scrollController.dispose(); _typingThrottle?.cancel(); @@ -63,7 +71,7 @@ class _MitraChatScreenState extends State { if (state is ChatConnected) { _scrollToBottom(); 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) .toList(); if (unread.isNotEmpty) { @@ -147,7 +155,7 @@ class _MitraChatScreenState extends State { itemCount: state.messages.length, itemBuilder: (context, index) { final msg = state.messages[index]; - final isMe = msg.senderType == 'mitra'; + final isMe = msg.senderType == UserType.mitra; return _buildMessageBubble(msg, isMe); }, ), @@ -204,11 +212,11 @@ class _MitraChatScreenState extends State { switch (status) { case 'sending': 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); - case 'delivered': + case MessageStatus.delivered: 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); default: return const SizedBox.shrink(); @@ -249,56 +257,64 @@ class _MitraChatScreenState extends State { final duration = request['duration_minutes'] as int?; final extensionId = request['extension_id'] as String?; - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.timer, size: 64, color: Colors.orange), - const SizedBox(height: 16), - const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center), - const SizedBox(height: 24), - Row( + return BlocBuilder( + builder: (context, extState) { + final isResponding = extState is ExtensionResponding; + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: Colors.green), - onPressed: () => context.read().add(RespondToExtension( - sessionId: widget.sessionId, - extensionId: extensionId!, - accepted: true, - )), - child: const Text('Terima', style: TextStyle(color: Colors.white)), - ), - const SizedBox(width: 16), - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: Colors.red), - onPressed: () => context.read().add(RespondToExtension( - sessionId: widget.sessionId, - extensionId: extensionId!, - accepted: false, - )), - child: const Text('Tolak', style: TextStyle(color: Colors.white)), - ), + const Icon(Icons.timer, size: 64, color: Colors.orange), + const SizedBox(height: 16), + const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center), + const SizedBox(height: 24), + if (isResponding) + const CircularProgressIndicator() + else + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.green), + onPressed: extensionId == null ? null : () => context.read().add(RespondToExtension( + sessionId: widget.sessionId, + extensionId: extensionId, + accepted: true, + )), + child: const Text('Terima', style: TextStyle(color: Colors.white)), + ), + const SizedBox(width: 16), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + onPressed: extensionId == null ? null : () => context.read().add(RespondToExtension( + sessionId: widget.sessionId, + extensionId: extensionId, + accepted: false, + )), + child: const Text('Tolak', style: TextStyle(color: Colors.white)), + ), + ], + ), ], ), - ], - ), - ), + ), + ); + }, ); } Widget _buildGoodbyeView(BuildContext context, ExtensionState extState) { final controller = TextEditingController(); - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ + return SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + const SizedBox(height: 48), const Icon(Icons.waving_hand, size: 64, color: Colors.amber), const SizedBox(height: 16), const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), @@ -331,7 +347,6 @@ class _MitraChatScreenState extends State { ), ], ), - ), ); } } diff --git a/mitra_app/lib/features/home/home_screen.dart b/mitra_app/lib/features/home/home_screen.dart index e566c3f..ccb3652 100644 --- a/mitra_app/lib/features/home/home_screen.dart +++ b/mitra_app/lib/features/home/home_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import '../../core/auth/auth_bloc.dart'; import '../../core/status/status_bloc.dart'; import '../../core/chat/chat_request_bloc.dart'; @@ -65,9 +66,11 @@ class _HomeScreenState extends State with WidgetsBindingObserver { if (state is ChatRequestIncoming) { _showIncomingRequest(state.sessionId); } else if (state is ChatRequestAccepted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Sesi baru diterima!')), - ); + final session = state.session; + 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), title: const Text('Sesi Aktif'), trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).pushNamed('/sessions'), + onTap: () => context.push('/sessions'), ), ), Card( @@ -185,7 +188,7 @@ class _ActiveSessionsButton extends StatelessWidget { leading: const Icon(Icons.history), title: const Text('Riwayat Chat'), trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).pushNamed('/chat/history'), + onTap: () => context.push('/chat/history'), ), ), ], diff --git a/mitra_app/lib/features/splash/splash_screen.dart b/mitra_app/lib/features/splash/splash_screen.dart new file mode 100644 index 0000000..653111d --- /dev/null +++ b/mitra_app/lib/features/splash/splash_screen.dart @@ -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(), + ], + ), + ), + ); + } +} diff --git a/mitra_app/lib/main.dart b/mitra_app/lib/main.dart index 42b057b..5e5e246 100644 --- a/mitra_app/lib/main.dart +++ b/mitra_app/lib/main.dart @@ -9,6 +9,7 @@ import 'core/status/status_bloc.dart'; import 'core/chat/chat_request_bloc.dart'; import 'core/chat/mitra_chat_bloc.dart'; import 'core/chat/extension_bloc.dart'; +import 'core/notifications/notification_service.dart'; import 'firebase_options.dart'; import 'router.dart'; @@ -43,6 +44,7 @@ class _AppState extends State with WidgetsBindingObserver { _apiClient = ApiClient(); _authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted()); _router = buildRouter(_authBloc); + NotificationService.initialize(_router); _statusBloc = StatusBloc(apiClient: _apiClient); _chatRequestBloc = ChatRequestBloc(apiClient: _apiClient); _registerFcmToken(); diff --git a/mitra_app/lib/router.dart b/mitra_app/lib/router.dart index cb5cbe2..edf9f42 100644 --- a/mitra_app/lib/router.dart +++ b/mitra_app/lib/router.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'core/auth/auth_bloc.dart'; +import 'features/splash/splash_screen.dart'; import 'features/auth/screens/login_screen.dart'; import 'features/auth/screens/otp_screen.dart'; import 'features/home/home_screen.dart'; @@ -26,19 +27,26 @@ class _BlocRefreshNotifier extends ChangeNotifier { GoRouter buildRouter(AuthBloc authBloc) { return GoRouter( - initialLocation: '/login', + initialLocation: '/splash', refreshListenable: _BlocRefreshNotifier(authBloc), redirect: (context, state) { final authState = authBloc.state; + final isSplash = state.matchedLocation == '/splash'; final isAuthRoute = state.matchedLocation.startsWith('/login') || state.matchedLocation.startsWith('/otp'); - if (authState is AuthLoading) return null; - if (authState is AuthAuthenticated) return isAuthRoute ? '/home' : null; - if (!isAuthRoute) return '/login'; + // Show splash while loading + if (authState is AuthLoading) return isSplash ? null : '/splash'; + + if (authState is AuthAuthenticated) { + return (isSplash || isAuthRoute) ? '/home' : null; + } + if (!isAuthRoute && !isSplash) return '/login'; + if (isSplash) return '/login'; return null; }, routes: [ + GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()), GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)), GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), diff --git a/mitra_app/pubspec.lock b/mitra_app/pubspec.lock index 3e6886f..2c47bfa 100644 --- a/mitra_app/pubspec.lock +++ b/mitra_app/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.35" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -65,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" dio: dependency: "direct main" description: @@ -97,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" firebase_auth: dependency: "direct main" description: @@ -190,6 +214,38 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: flutter @@ -208,6 +264,14 @@ packages: url: "https://pub.dev" source: hosted version: "13.2.5" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_parser: dependency: transitive description: @@ -304,6 +368,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -373,6 +445,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + timezone: + dependency: transitive + description: + name: timezone + sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" + url: "https://pub.dev" + source: hosted + version: "0.11.0" typed_data: dependency: transitive description: @@ -413,6 +493,22 @@ packages: url: "https://pub.dev" source: hosted 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: - dart: ">=3.8.0-0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.1" diff --git a/mitra_app/pubspec.yaml b/mitra_app/pubspec.yaml index 11f0c6a..4636871 100644 --- a/mitra_app/pubspec.yaml +++ b/mitra_app/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: # Navigation go_router: ^13.2.1 + flutter_local_notifications: ^21.0.0 dev_dependencies: flutter_test: diff --git a/requirement/phase3.md b/requirement/phase3.md index 016f6f8..c38ec43 100644 --- a/requirement/phase3.md +++ b/requirement/phase3.md @@ -14,6 +14,16 @@ # 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 Functional requirement: - 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 - 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 +- 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 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 session is finished there will be following functionality: - Customer Application: - Show dialog window whether Customer want to extend the session - 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)