commit 57017dd397c404a5bb4065a8e69de859f4f273c5 Author: Wira Irawan Date: Mon May 11 11:36:33 2026 +0700 Initial BizOne portal setup diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6d360ff --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/wa_dashboard +JWT_SECRET=replace-with-32-plus-char-random-secret +JWT_EXPIRES_IN=1d +JWT_REFRESH_SECRET=replace-with-32-plus-char-random-refresh-secret +JWT_REFRESH_EXPIRES_IN=30d +PORT=3001 +FRONTEND_ORIGIN=http://localhost:3000 +PUBLIC_API_URL=http://localhost:3001 +REDIS_URL=redis://127.0.0.1:6379 +WEBHOOK_VERIFY_TOKEN=replace-with-32-plus-char-random-token +WEBHOOK_SHARED_SECRET=replace-with-32-plus-char-random-secret +META_WEBHOOK_APP_SECRET= +WEBHOOK_ALLOW_UNSIGNED=false +NEXT_PUBLIC_API_URL=http://localhost:3001/api +MAIL_HOST=mail.example.com +MAIL_PORT=465 +MAIL_SECURE=true +MAIL_USER=no-reply@example.com +MAIL_PASSWORD=replace-with-real-smtp-password +MAIL_FROM=no-reply@example.com +AUTH_LOGIN_MAX_ATTEMPTS=5 +AUTH_LOGIN_WINDOW_MINUTES=15 +AUTH_2FA_MAX_ATTEMPTS=5 +AUTH_2FA_WINDOW_MINUTES=10 +AUTH_PASSWORD_RESET_MAX_ATTEMPTS=3 +AUTH_PASSWORD_RESET_WINDOW_MINUTES=30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44d0390 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +.DS_Store +.env +.env.local +.env.*.local + +node_modules/ +frontend/node_modules/ +backend/node_modules/ + +dist/ +backend/dist/ +frontend/.next/ +frontend/out/ + +.runlogs/ +*.log + +coverage/ +.coverage + +.idea/ +.vscode/ + +postgres_data/ +redis_data/ diff --git a/PRODUCTION_CHECKLIST.md b/PRODUCTION_CHECKLIST.md new file mode 100644 index 0000000..1df3cd4 --- /dev/null +++ b/PRODUCTION_CHECKLIST.md @@ -0,0 +1,133 @@ +# Production Checklist + +Checklist ini dipakai untuk menilai apakah `bizone-web` sudah layak masuk staging dan production. + +Status yang dipakai: +- `[x]` sudah siap +- `[~]` sebagian / perlu verifikasi lanjutan +- `[ ]` belum siap + +## Critical Before Go-Live + +- [ ] Meta webhook handshake diuji dengan callback URL publik `https` +- [ ] Meta outbound send diuji dengan `accessToken` dan `phoneNumberId` nyata +- [ ] Meta status callback (`sent`, `delivered`, `read`, `failed`) diverifikasi masuk ke sistem +- [ ] Permission audit selesai untuk role `admin`, `editor`, dan `agent` +- [ ] Secrets production dipindah ke env/secret manager nyata +- [ ] Backup database dan restore drill dibuktikan +- [ ] Staging environment tersedia dan menyerupai production +- [ ] CI/CD deploy flow menjalankan build, migrate, dan smoke test +- [ ] Monitoring dan alerting aktif untuk backend, DB, Redis, webhook, dan queue +- [ ] Full smoke test lintas auth, templates, campaigns, conversations, webhook, dan settings selesai + +## Auth And Security + +- [x] Login flow aktif +- [x] Refresh token + logout invalidation aktif +- [x] Redis rate limiting untuk login flow aktif +- [x] Forgot password flow aktif +- [x] Reset password flow aktif +- [x] 2FA + recovery codes aktif +- [~] Security notification emails sudah diimplementasikan, belum diuji SMTP end-to-end +- [~] Session management baru `single-session` +- [ ] Multi-device session history +- [ ] Revoke session per device +- [ ] Security event review workflow / alert dashboard + +## Users, Roles, Permissions + +- [x] Role CRUD tersedia +- [x] Permission guard backend untuk `templates`, `campaigns`, `users`, `roles` +- [~] Fallback permission matrix tersedia untuk `admin`, `editor`, `agent` +- [ ] Audit semua route sensitif lain di backend +- [ ] Role-based test cases untuk `editor` dan `agent` +- [ ] Frontend permission-aware UX yang konsisten + +## Templates + +- [x] Model database `message_templates` +- [x] Migrasi template aktif +- [x] Template list live dari backend +- [x] Template builder create/edit live +- [x] Search/filter template dasar +- [ ] Delete/archive template +- [ ] Versioning template +- [ ] Approval sync dengan Meta +- [ ] Reject reason sync dari Meta + +## Campaigns + +- [x] Campaign CRUD internal tersedia +- [x] Campaign create/update memvalidasi template live +- [x] Queue scheduling dasar tersedia +- [~] Campaign delivery/reporting masih dominan internal +- [ ] Campaign form memakai dropdown/source template live +- [ ] Audience resolution yang matang +- [ ] Deduplication tervalidasi +- [ ] Retry policy diaudit end-to-end +- [ ] Delivery tracking real dari Meta diuji live + +## Conversations + +- [x] Conversation list/detail live +- [x] Reply tersimpan ke DB +- [x] Inbound webhook sync ke inbox +- [x] Assignment dasar tersedia +- [x] Unread/read flow dasar tersedia +- [~] Outbound provider path sudah ada, belum diuji ke Meta real +- [ ] Internal notes +- [ ] Rich agent tooling / SLA / escalation flow + +## Webhook And Integrations + +- [x] WhatsApp integration settings tersedia +- [x] Verify token flow tersedia +- [x] Signature validation path tersedia +- [x] Webhook retry/replay dasar tersedia +- [x] Callback URL production target sudah ditetapkan: `https://portal.bizone.id/api/webhooks/whatsapp` +- [x] Health check production target sudah ditetapkan: `https://portal.bizone.id/api/health` +- [ ] Provider real test terhadap Meta +- [ ] Failure handling terhadap response Meta nyata tervalidasi +- [ ] Webhook observability yang lebih matang + +## Infra And Ops + +- [x] Env validation production dasar tersedia +- [x] HTTPS constraints production dasar tersedia +- [x] CORS production config dasar tersedia +- [x] Artefak deploy Debian 12 tersedia di `deploy/debian12` +- [ ] Staging deployment final +- [ ] Reverse proxy/domain setup final +- [ ] Backup/restore SOP terdokumentasi +- [ ] Log aggregation / error tracking +- [ ] Queue monitoring dashboard matang +- [ ] Incident runbook + +## Build And Release + +- [x] Backend build sukses +- [x] Frontend build sukses +- [x] Prisma migration flow aktif +- [x] Legacy baseline script tersedia +- [ ] Automated deploy pipeline final +- [ ] Post-deploy smoke checks terdokumentasi +- [ ] Rollback strategy terdokumentasi + +## Recommended Order + +1. Siapkan `staging`. +2. Sambungkan dan uji `Meta` end-to-end. +3. Audit `permissions` untuk semua role. +4. Lengkapi `campaign UI` agar memakai template live. +5. Pasang `monitoring`, `backup`, dan `CI/CD`. +6. Jalankan full smoke test. +7. Baru deploy production. + +## Production Targets + +- App URL: `https://portal.bizone.id` +- API base URL: `https://portal.bizone.id/api` +- Health check: `https://portal.bizone.id/api/health` +- Meta callback URL: `https://portal.bizone.id/api/webhooks/whatsapp` +- Meta verify token source: env `WEBHOOK_VERIFY_TOKEN` +- Alternate provider webhook URL: `https://portal.bizone.id/api/webhooks/whatsapp/meta` diff --git a/README.md b/README.md new file mode 100644 index 0000000..e503f3c --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# WA Dashboard Kit V6 + +Versi 6 fokus bikin flow dasar lebih terasa hidup. + +## Added in V6 +- Login page skeleton +- Frontend token helper +- Contacts create form skeleton +- Better auth notes +- Realistic next-step structure for turning into full app + +## Note +Masih starter baseline, belum production-ready. + +## Production Security Baseline +- `JWT_SECRET`, `WEBHOOK_VERIFY_TOKEN`, dan `WEBHOOK_SHARED_SECRET` wajib kuat. +- Di `production`, secret di atas wajib minimal 32 karakter dan tidak boleh pakai placeholder. +- `JWT_EXPIRES_IN` harus ditetapkan eksplisit sesuai kebijakan sesi. +- `PUBLIC_API_URL` dan `FRONTEND_ORIGIN` wajib `https` di `production`. +- `WEBHOOK_ALLOW_UNSIGNED` tidak boleh aktif di `production`. +- Jika SMTP dipakai di `production`, set lengkap `MAIL_HOST`, `MAIL_USER`, `MAIL_PASSWORD`, dan `MAIL_FROM`. +- Login sekarang dibatasi oleh `AUTH_LOGIN_MAX_ATTEMPTS` dan `AUTH_LOGIN_WINDOW_MINUTES`. +- Verifikasi 2FA dan forgot-password juga dibatasi lewat Redis: + `AUTH_2FA_MAX_ATTEMPTS`, `AUTH_2FA_WINDOW_MINUTES`, + `AUTH_PASSWORD_RESET_MAX_ATTEMPTS`, dan `AUTH_PASSWORD_RESET_WINDOW_MINUTES`. +- 2FA sekarang mendukung backup recovery codes satu-kali-pakai. +- Lihat `.env.example` untuk kontrak env terbaru. + +## Local Run +Frontend: +`cd frontend && npm run dev` + +Backend di environment ini lebih stabil lewat build output karena `tsx watch` gagal pada Node.js 25: +`cd backend && npm run local` + +## Legacy Prisma Baseline +Untuk database lama yang sudah punya tabel tetapi belum punya histori `_prisma_migrations`, jalankan: +`cd backend && npm run db:baseline:legacy` + +Gunakan ini hanya jika schema database memang sudah setara dengan isi folder `prisma/migrations`. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..322c41d --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine +WORKDIR /app + +COPY backend/package*.json ./backend/ +COPY prisma ./prisma + +WORKDIR /app/backend +RUN npm ci + +COPY backend ./ +RUN npx prisma generate --schema ../prisma/schema.prisma +RUN npm run build + +EXPOSE 3001 +CMD ["npm", "run", "start:prod"] diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..c4c8f03 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2787 @@ +{ + "name": "wa-dashboard-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wa-dashboard-backend", + "version": "1.0.0", + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/platform-express": "^10.0.0", + "@prisma/client": "^5.0.0", + "bcryptjs": "^2.4.3", + "bullmq": "^5.76.6", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "dotenv": "^17.4.2", + "ioredis": "^5.10.1", + "nodemailer": "^8.0.7", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^22.10.1", + "@types/nodemailer": "^8.0.0", + "prisma": "^5.22.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@nestjs/common": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", + "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", + "license": "MIT", + "dependencies": { + "file-type": "20.4.1", + "iterare": "1.2.1", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/core": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.22.tgz", + "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@nuxtjs/opencollective": "0.3.2", + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "3.3.0", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/microservices": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/jwt": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", + "integrity": "sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.5", + "jsonwebtoken": "9.0.2" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@nestjs/platform-express": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", + "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", + "license": "MIT", + "dependencies": { + "body-parser": "1.20.4", + "cors": "2.8.5", + "express": "4.22.1", + "multer": "2.0.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0" + } + }, + "node_modules/@nuxtjs/opencollective": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", + "integrity": "sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "consola": "^2.15.0", + "node-fetch": "^2.6.1" + }, + "bin": { + "opencollective": "bin/opencollective.js" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@tokenizer/inflate": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", + "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fflate": "^0.8.2", + "token-types": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/inflate/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@tokenizer/inflate/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", + "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bullmq": { + "version": "5.76.6", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.76.6.tgz", + "integrity": "sha512-vlmL3B3NVMRy6se3c7jPHn1Nhqxrg7+wlv1t3XAQFBYZNJDMLP0OO5x2AX5ca7DAuS1SU/C+VfYi+NHVoFK1QQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "2.0.1", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", + "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/consola": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", + "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/file-type": { + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.4.1.tgz", + "integrity": "sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.2.6", + "strtok3": "^10.2.0", + "token-types": "^6.0.0", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.2", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.12.43", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.43.tgz", + "integrity": "sha512-5n+HnmkNpgZCfaNVxrTGZHr6Lhv3gd0UtbD5lrzun3T2YNyVvCuJz9vkap2E0YWZWU1XF+0XljYAkrAJBbwbrg==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.1.tgz", + "integrity": "sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/nodemailer": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "license": "MIT" + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "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/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..d2cad13 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,44 @@ +{ + "name": "wa-dashboard-backend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "tsx watch src/main.ts", + "start:dev": "tsx src/main.ts", + "local": "npm run build && node dist/main.js", + "start": "node dist/main.js", + "start:prod": "node dist/main.js", + "build": "tsc -p tsconfig.json", + "db:generate": "prisma generate --schema ../prisma/schema.prisma && rm -rf node_modules/@prisma node_modules/.prisma node_modules/.package-lock.json && mkdir -p node_modules && cp -R ../node_modules/@prisma node_modules/@prisma && cp -R ../node_modules/.prisma node_modules/.prisma && rm -rf node_modules/@prisma/client/.prisma && mkdir -p node_modules/@prisma/client/.prisma && cp -R ../node_modules/.prisma/client node_modules/@prisma/client/.prisma/client", + "db:migrate:dev": "prisma migrate dev --schema ../prisma/schema.prisma", + "db:migrate:deploy": "prisma migrate deploy --schema ../prisma/schema.prisma", + "db:baseline:legacy": "node ../prisma/scripts/baseline-legacy-db.mjs", + "seed:admin": "node prisma/seed-admin.js", + "seed:campaigns": "node prisma/seed-campaigns.js" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/platform-express": "^10.0.0", + "@prisma/client": "^5.0.0", + "bcryptjs": "^2.4.3", + "bullmq": "^5.76.6", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "dotenv": "^17.4.2", + "ioredis": "^5.10.1", + "nodemailer": "^8.0.7", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^22.10.1", + "@types/nodemailer": "^8.0.0", + "prisma": "^5.22.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/backend/prisma/seed-admin.js b/backend/prisma/seed-admin.js new file mode 100644 index 0000000..e63b9ee --- /dev/null +++ b/backend/prisma/seed-admin.js @@ -0,0 +1,42 @@ +const path = require('node:path'); +const { PrismaClient } = require('@prisma/client'); +const bcrypt = require('bcryptjs'); +const dotenv = require('dotenv'); + +dotenv.config({ path: path.resolve(process.cwd(), '../.env'), quiet: true }); +dotenv.config({ path: path.resolve(process.cwd(), '.env'), quiet: true }); + +const prisma = new PrismaClient(); + +async function main() { + const email = 'admin@example.com'; + const password = 'ChangeMe123!'; + const passwordHash = await bcrypt.hash(password, 10); + + const user = await prisma.user.upsert({ + where: { email }, + update: { + name: 'System Admin', + passwordHash, + status: 'active', + }, + create: { + name: 'System Admin', + email, + passwordHash, + status: 'active', + }, + }); + + console.log(`Seeded admin user: ${user.email}`); + console.log(`Recommended credentials: ${email} / ${password}`); +} + +main() + .catch((error) => { + console.error(error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/prisma/seed-campaigns.js b/backend/prisma/seed-campaigns.js new file mode 100644 index 0000000..d14d619 --- /dev/null +++ b/backend/prisma/seed-campaigns.js @@ -0,0 +1,180 @@ +const path = require('node:path'); +const { PrismaClient } = require('@prisma/client'); +const dotenv = require('dotenv'); +const { randomUUID } = require('node:crypto'); + +dotenv.config({ path: path.resolve(process.cwd(), '../.env'), quiet: true }); +dotenv.config({ path: path.resolve(process.cwd(), '.env'), quiet: true }); + +const prisma = new PrismaClient(); + +const sentAt = new Date('2024-07-15T09:45:00.000Z'); + +const campaigns = [ + { + code: 'CAM-98231', + name: 'Summer Sale 2024', + audienceLabel: '45,200 recipients', + audienceGroup: 'Retail subscribers', + status: 'Sent', + totalRecipients: 48250, + deliveredCount: 47482, + readCount: 30987, + failedCount: 578, + deliveryRate: 98.4, + readRate: 64.2, + sentAt, + templateName: 'summer_promo_v2', + language: 'English (US)', + messageTitle: 'Hi {{name}}, ☀️', + messageBody: 'Our Summer Sale is here! Get up to 40% OFF on all new arrivals. Use code SUMMER40 at checkout.', + primaryButton: 'Shop Collection', + secondaryButton: 'View Catalog', + bannerImageUrl: 'https://lh3.googleusercontent.com/aida-public/AB6AXuDEStTHrI49NhOpgRMdXx3saVUtVNe9fBtTvDiMZMeuDcQNU8eJHfAxc5hS5M8ligofVNNpUi59-kOLD9peg5njH1bWmsrHGXIx7A37_pAFEfxEAGVbjVjWCD0mGWIHu4LIShS9yDlFmvznUPzlye_JNLPzs7S8LIULMi-bL7cP6qt-uzXKuoWUwj1sw0uq0UcJnkCb6Y-04pNG8iNd2MINCbLOSbmRyf8OSOe1b9-u-sA6p5Mq3CKRjP-Fvk0vk3ZKdritVLiB0U8', + recipients: [ + { phoneNumber: '+1 (555) 012-3456', status: 'Read', sentAt: new Date('2024-07-15T10:12:00.000Z'), deviceOs: 'Android' }, + { phoneNumber: '+1 (555) 012-7890', status: 'Delivered', sentAt: new Date('2024-07-15T09:48:00.000Z'), deviceOs: 'iOS' }, + { phoneNumber: '+1 (555) 013-1122', status: 'Failed', sentAt: new Date('2024-07-15T09:45:00.000Z'), errorReason: 'Policy Violation', deviceOs: 'Android' }, + { phoneNumber: '+1 (555) 014-3344', status: 'Read', sentAt: new Date('2024-07-15T10:05:00.000Z'), deviceOs: 'Android' }, + { phoneNumber: '+1 (555) 015-5566', status: 'Delivered', sentAt: new Date('2024-07-15T09:52:00.000Z'), deviceOs: 'iOS' }, + ], + }, + { + code: 'CAM-98244', + name: 'Weekly Newsletter #42', + audienceLabel: 'VIP Customer List', + audienceGroup: 'High-value segment', + status: 'Scheduled', + totalRecipients: 18640, + deliveredCount: 0, + readCount: 0, + failedCount: 0, + deliveryRate: null, + readRate: null, + scheduledAt: new Date('2024-07-16T14:00:00.000Z'), + templateName: 'vip_newsletter_v42', + language: 'English (US)', + messageTitle: 'Your insider update is almost here', + messageBody: 'A curated digest for premium customers with fresh offers and product stories.', + primaryButton: 'Open Newsletter', + secondaryButton: 'Manage Preferences', + bannerImageUrl: 'https://images.unsplash.com/photo-1520607162513-77705c0f0d4a?auto=format&fit=crop&w=800&q=80', + recipients: [], + }, + { + code: 'CAM-98255', + name: 'Product Launch Promo', + audienceLabel: 'New Leads Segment', + audienceGroup: 'Cold outreach', + status: 'Draft', + totalRecipients: 9300, + deliveredCount: 0, + readCount: 0, + failedCount: 0, + deliveryRate: 0, + readRate: 0, + templateName: 'launch_teaser_v1', + language: 'English (US)', + messageTitle: 'Be first to see the launch', + messageBody: 'A teaser sequence for high-intent prospects before the public product announcement.', + primaryButton: 'Join Waitlist', + secondaryButton: 'See Features', + bannerImageUrl: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=800&q=80', + recipients: [], + }, + { + code: 'CAM-98201', + name: 'Loyalty Program Update', + audienceLabel: 'Dormant Users', + audienceGroup: 'Reactivation list', + status: 'Failed', + totalRecipients: 60700, + deliveredCount: 27315, + readCount: 10844, + failedCount: 33385, + deliveryRate: 45, + readRate: 17.9, + sentAt: new Date('2024-07-10T08:30:00.000Z'), + templateName: 'loyalty_reactivation_v1', + language: 'English (US)', + messageTitle: 'We saved your rewards for you', + messageBody: 'A recovery campaign for inactive users with points reminder and welcome-back offer.', + primaryButton: 'Claim Rewards', + secondaryButton: 'Need Help', + bannerImageUrl: 'https://images.unsplash.com/photo-1515169067868-5387ec356754?auto=format&fit=crop&w=800&q=80', + recipients: [], + }, +]; + +async function main() { + for (const campaign of campaigns) { + await prisma.campaign.upsert({ + where: { code: campaign.code }, + update: { + name: campaign.name, + audienceLabel: campaign.audienceLabel, + audienceGroup: campaign.audienceGroup, + status: campaign.status, + totalRecipients: campaign.totalRecipients, + deliveredCount: campaign.deliveredCount, + readCount: campaign.readCount, + failedCount: campaign.failedCount, + deliveryRate: campaign.deliveryRate, + readRate: campaign.readRate, + sentAt: campaign.sentAt || null, + scheduledAt: campaign.scheduledAt || null, + templateName: campaign.templateName, + language: campaign.language, + messageTitle: campaign.messageTitle, + messageBody: campaign.messageBody, + primaryButton: campaign.primaryButton, + secondaryButton: campaign.secondaryButton, + bannerImageUrl: campaign.bannerImageUrl, + }, + create: { + id: randomUUID(), + code: campaign.code, + name: campaign.name, + audienceLabel: campaign.audienceLabel, + audienceGroup: campaign.audienceGroup, + status: campaign.status, + totalRecipients: campaign.totalRecipients, + deliveredCount: campaign.deliveredCount, + readCount: campaign.readCount, + failedCount: campaign.failedCount, + deliveryRate: campaign.deliveryRate, + readRate: campaign.readRate, + sentAt: campaign.sentAt || null, + scheduledAt: campaign.scheduledAt || null, + templateName: campaign.templateName, + language: campaign.language, + messageTitle: campaign.messageTitle, + messageBody: campaign.messageBody, + primaryButton: campaign.primaryButton, + secondaryButton: campaign.secondaryButton, + bannerImageUrl: campaign.bannerImageUrl, + recipients: { + create: campaign.recipients.map((recipient) => ({ + id: randomUUID(), + phoneNumber: recipient.phoneNumber, + status: recipient.status, + sentAt: recipient.sentAt || null, + errorReason: recipient.errorReason || null, + deviceOs: recipient.deviceOs || null, + })), + }, + }, + }); + } + + console.log(`Seeded ${campaigns.length} campaigns.`); +} + +main() + .catch((error) => { + console.error(error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 0000000..c0ec81d --- /dev/null +++ b/backend/src/app.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from './auth/auth.module'; +import { ContactsModule } from './contacts/contacts.module'; +import { WebhooksModule } from './webhooks/webhooks.module'; +import { PrismaModule } from './prisma/prisma.module'; +import { JobsModule } from './jobs/jobs.module'; +import { DashboardModule } from './dashboard/dashboard.module'; +import { IntegrationsModule } from './integrations/integrations.module'; +import { HealthModule } from './health/health.module'; +import { LogsModule } from './logs/logs.module'; +import { RolesModule } from './roles/roles.module'; +import { MailerModule } from './mailer/mailer.module'; +import { UsersModule } from './users/users.module'; +import { CampaignsModule } from './campaigns/campaigns.module'; +import { ConversationsModule } from './conversations/conversations.module'; +import { TemplatesModule } from './templates/templates.module'; + +@Module({ + imports: [ + PrismaModule, + JobsModule, + MailerModule, + AuthModule, + ContactsModule, + WebhooksModule, + DashboardModule, + IntegrationsModule, + HealthModule, + LogsModule, + RolesModule, + UsersModule, + TemplatesModule, + CampaignsModule, + ConversationsModule, + ], +}) +export class AppModule {} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..c3e045a --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -0,0 +1,130 @@ +import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { AuthService } from './auth.service'; +import { LoginDto } from './dto/login.dto'; +import { AuthGuard } from '../common/auth.guard'; +import { AuthenticatedUser } from './auth.types'; +import { RefreshTokenDto } from './dto/refresh-token.dto'; +import { LogoutDto } from './dto/logout.dto'; +import { RequestPasswordResetDto } from './dto/request-password-reset.dto'; +import { CompletePasswordResetDto } from './dto/complete-password-reset.dto'; +import { TwoFactorCodeDto } from './dto/two-factor-code.dto'; +import { VerifyTwoFactorLoginDto } from './dto/verify-two-factor-login.dto'; + +@Controller('auth') +export class AuthController { + private readonly authService: AuthService; + + constructor(authService: AuthService) { + this.authService = authService; + } + + @Post('login') + signIn(@Req() request: Request, @Body() body: LoginDto) { + const forwarded = request.headers['x-forwarded-for']; + const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip; + return this.authService.login(body.email, body.password, ipAddress); + } + + @Post('refresh') + refresh(@Req() request: Request, @Body() body: RefreshTokenDto) { + const forwarded = request.headers['x-forwarded-for']; + const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip; + return this.authService.refresh(body.refreshToken, ipAddress); + } + + @Post('logout') + signOut(@Req() request: Request, @Body() body: LogoutDto) { + const forwarded = request.headers['x-forwarded-for']; + const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip; + const authHeader = request.headers.authorization; + const token = + typeof authHeader === 'string' && authHeader.startsWith('Bearer ') + ? authHeader.slice('Bearer '.length).trim() + : undefined; + return this.authService.logout({ accessToken: token, refreshToken: body.refreshToken, ipAddress }); + } + + @Post('forgot-password') + forgotPassword(@Req() request: Request, @Body() body: RequestPasswordResetDto) { + const forwarded = request.headers['x-forwarded-for']; + const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip; + return this.authService.requestPasswordReset(body.email, ipAddress); + } + + @Get('password-reset/:token') + getPasswordReset(@Param('token') token: string) { + return this.authService.getPasswordResetToken(token); + } + + @Post('password-reset/:token') + completePasswordReset( + @Req() request: Request, + @Param('token') token: string, + @Body() body: CompletePasswordResetDto, + ) { + const forwarded = request.headers['x-forwarded-for']; + const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip; + return this.authService.completePasswordReset(token, body.password, ipAddress); + } + + @UseGuards(AuthGuard) + @Get('me') + getMe(@Req() request: Request & { user: AuthenticatedUser }) { + return this.authService.me(request.user.sub); + } + + @UseGuards(AuthGuard) + @Get('session') + getCurrentSession(@Req() request: Request & { user: AuthenticatedUser }) { + const forwarded = request.headers['x-forwarded-for']; + const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip; + return this.authService.getCurrentSession(request.user, ipAddress); + } + + @UseGuards(AuthGuard) + @Get('2fa/status') + getTwoFactorStatus(@Req() request: Request & { user: AuthenticatedUser }) { + return this.authService.getTwoFactorStatus(request.user.sub); + } + + @UseGuards(AuthGuard) + @Post('2fa/setup/initiate') + initiateTwoFactorSetup(@Req() request: Request & { user: AuthenticatedUser }) { + return this.authService.initiateTwoFactorSetup(request.user.sub); + } + + @UseGuards(AuthGuard) + @Post('2fa/setup/confirm') + confirmTwoFactorSetup( + @Req() request: Request & { user: AuthenticatedUser }, + @Body() body: TwoFactorCodeDto, + ) { + return this.authService.confirmTwoFactorSetup(request.user.sub, body.code, request.ip); + } + + @UseGuards(AuthGuard) + @Post('2fa/disable') + disableTwoFactor( + @Req() request: Request & { user: AuthenticatedUser }, + @Body() body: TwoFactorCodeDto, + ) { + return this.authService.disableTwoFactor(request.user.sub, body.code, request.ip); + } + + @UseGuards(AuthGuard) + @Post('2fa/recovery-codes/regenerate') + regenerateTwoFactorRecoveryCodes( + @Req() request: Request & { user: AuthenticatedUser }, + @Body() body: TwoFactorCodeDto, + ) { + return this.authService.regenerateTwoFactorRecoveryCodes(request.user.sub, body.code, request.ip); + } + + @Post('2fa/login/verify') + verifyTwoFactorLogin(@Req() request: Request, @Body() body: VerifyTwoFactorLoginDto) { + const forwarded = request.headers['x-forwarded-for']; + const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip; + return this.authService.verifyTwoFactorLogin(body.challengeToken, body.code, ipAddress); + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..023dae2 --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { getAppConfig } from '../config/env'; +import { MailerModule } from '../mailer/mailer.module'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; + +const config = getAppConfig(); + +@Module({ + imports: [ + MailerModule, + JwtModule.register({ + secret: config.jwtSecret, + signOptions: { expiresIn: config.jwtExpiresIn }, + }), + ], + controllers: [AuthController], + providers: [AuthService], + exports: [JwtModule, AuthService], +}) +export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..1ed5794 --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -0,0 +1,1088 @@ +import { HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { JwtService } from '@nestjs/jwt'; +import { createHash, randomBytes } from 'node:crypto'; +import { normalizeEmail } from '../common/normalize'; +import { comparePassword, hasMinimumPasswordLength, hashPassword } from '../common/password'; +import { getAppConfig } from '../config/env'; +import { RedisQueueService } from '../jobs/redis-queue.service'; +import { MailerService } from '../mailer/mailer.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuthenticatedUser } from './auth.types'; +import { buildOtpAuthUrl, decryptSecret, encryptSecret, generateTotpSecret, verifyTotpCode } from './totp'; + +const config = getAppConfig(); +const TWO_FACTOR_LOGIN_CHALLENGE_TTL = '10m'; +const LOGIN_LIMITER = { + scope: 'login', + maxAttempts: config.authLoginMaxAttempts, + windowMinutes: config.authLoginWindowMinutes, + message: 'Too many login attempts.', +} as const; +const TWO_FACTOR_LIMITER = { + scope: '2fa', + maxAttempts: config.authTwoFactorMaxAttempts, + windowMinutes: config.authTwoFactorWindowMinutes, + message: 'Too many two-factor verification attempts.', +} as const; +const PASSWORD_RESET_LIMITER = { + scope: 'password-reset', + maxAttempts: config.authPasswordResetMaxAttempts, + windowMinutes: config.authPasswordResetWindowMinutes, + message: 'Too many password reset requests.', +} as const; + +function formatSecurityTimestamp(value: Date) { + return value.toISOString(); +} + +type TwoFactorChallengePayload = AuthenticatedUser & { purpose: '2fa-login' }; +type TwoFactorRecord = { + twoFactorEnabled: boolean; + twoFactorSecretEncrypted: string | null; + twoFactorPendingSecretEncrypted: string | null; + twoFactorRecoveryCodesHashJson: unknown; + twoFactorConfirmedAt: Date | null; +}; + +@Injectable() +export class AuthService { + constructor( + private readonly jwtService: JwtService, + private readonly prisma: PrismaService, + private readonly redisQueueService: RedisQueueService, + private readonly mailer: MailerService, + ) {} + + async login(email: string, password: string, ipAddress?: string) { + const normalizedEmail = normalizeEmail(email); + await this.assertRateLimit(LOGIN_LIMITER, normalizedEmail, ipAddress); + + const user = await this.prisma.user.findUnique({ + where: { email: normalizedEmail }, + }); + + if (!user) { + await this.recordFailedLogin({ + email: normalizedEmail, + ipAddress, + reason: 'Unknown email', + }); + await this.registerFailedAttempt(LOGIN_LIMITER, normalizedEmail, ipAddress); + throw new UnauthorizedException('Invalid email or password'); + } + + if (!user.passwordHash) { + await this.recordFailedLogin({ + userId: user.id, + name: user.name, + email: user.email, + ipAddress, + reason: 'Password setup incomplete', + }); + await this.registerFailedAttempt(LOGIN_LIMITER, normalizedEmail, ipAddress); + throw new UnauthorizedException('User account has not completed password setup'); + } + + const isPasswordValid = await comparePassword(password, user.passwordHash); + if (!isPasswordValid) { + await this.recordFailedLogin({ + userId: user.id, + name: user.name, + email: user.email, + ipAddress, + reason: 'Invalid password', + }); + await this.registerFailedAttempt(LOGIN_LIMITER, normalizedEmail, ipAddress); + throw new UnauthorizedException('Invalid email or password'); + } + + if (user.status !== 'active') { + await this.recordFailedLogin({ + userId: user.id, + name: user.name, + email: user.email, + ipAddress, + reason: `User status ${user.status}`, + }); + await this.registerFailedAttempt(LOGIN_LIMITER, normalizedEmail, ipAddress); + throw new UnauthorizedException('User account is not active'); + } + + const payload: AuthenticatedUser = { + sub: user.id, + email: user.email, + ver: user.sessionVersion, + }; + + await this.clearFailedAttempts(LOGIN_LIMITER, normalizedEmail, ipAddress); + + const twoFactor = await this.getTwoFactorRecord(user.id); + if (twoFactor?.twoFactorEnabled && twoFactor.twoFactorSecretEncrypted) { + const challengeToken = await this.jwtService.signAsync( + { + ...payload, + purpose: '2fa-login', + } satisfies TwoFactorChallengePayload, + { + secret: config.jwtSecret, + expiresIn: TWO_FACTOR_LOGIN_CHALLENGE_TTL, + }, + ); + + return { + requiresTwoFactor: true, + challengeToken, + challengeExpiresIn: TWO_FACTOR_LOGIN_CHALLENGE_TTL, + user: { + id: user.id, + name: user.name, + email: user.email, + status: user.status, + }, + }; + } + + const session = await this.issueSession(payload, user.id); + + await this.prisma.auditLog.create({ + data: { + actorUserId: user.id, + actorName: user.name, + actorEmail: user.email, + actionType: 'Login Success', + module: 'Auth Gateway', + ipAddress: ipAddress || null, + severity: 'default', + details: `Successful login for ${user.email}.`, + }, + }); + + await this.prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }); + + return { + ...session, + user: { + id: user.id, + name: user.name, + email: user.email, + status: user.status, + }, + }; + } + + async verifyAccessToken(token: string) { + const payload = await this.jwtService.verifyAsync(token, { + secret: config.jwtSecret, + }); + + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + select: { id: true, email: true, status: true, sessionVersion: true }, + }); + + if (!user || user.status !== 'active' || user.sessionVersion !== payload.ver || user.email !== payload.email) { + throw new UnauthorizedException('Invalid or expired token'); + } + + return payload; + } + + async refresh(refreshToken: string, ipAddress?: string) { + const payload = await this.verifyRefreshToken(refreshToken); + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + select: { + id: true, + name: true, + email: true, + status: true, + sessionVersion: true, + refreshTokenHash: true, + refreshTokenExpiresAt: true, + }, + }); + + if ( + !user || + user.status !== 'active' || + user.sessionVersion !== payload.ver || + user.email !== payload.email || + !user.refreshTokenHash || + user.refreshTokenHash !== this.hashToken(refreshToken) || + !user.refreshTokenExpiresAt || + user.refreshTokenExpiresAt.getTime() <= Date.now() + ) { + throw new UnauthorizedException('Invalid or expired refresh token'); + } + + const nextPayload: AuthenticatedUser = { + sub: user.id, + email: user.email, + ver: user.sessionVersion, + }; + const session = await this.issueSession(nextPayload, user.id); + + await this.prisma.auditLog.create({ + data: { + actorUserId: user.id, + actorName: user.name, + actorEmail: user.email, + actionType: 'Token Refreshed', + module: 'Auth Gateway', + ipAddress: ipAddress || null, + severity: 'default', + details: `Refreshed session for ${user.email}.`, + }, + }); + + return session; + } + + async logout(input: { + accessToken?: string; + refreshToken?: string; + ipAddress?: string; + }) { + let userId: string | null = null; + let userEmail: string | null = null; + let actorName: string | null = null; + + if (input.accessToken) { + try { + const payload = await this.verifyAccessToken(input.accessToken); + userId = payload.sub; + userEmail = payload.email; + } catch { + userId = null; + } + } + + if (!userId && input.refreshToken) { + const payload = await this.verifyRefreshToken(input.refreshToken); + userId = payload.sub; + userEmail = payload.email; + } + + if (!userId || !userEmail) { + throw new UnauthorizedException('Unable to resolve session for logout'); + } + + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, name: true, email: true, sessionVersion: true }, + }); + + if (!user) { + throw new UnauthorizedException('User not found'); + } + + actorName = user.name; + + await this.prisma.user.update({ + where: { id: user.id }, + data: { + refreshTokenHash: null, + refreshTokenExpiresAt: null, + sessionVersion: { + increment: 1, + }, + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: user.id, + actorName, + actorEmail: user.email, + actionType: 'Logout Success', + module: 'Auth Gateway', + ipAddress: input.ipAddress || null, + severity: 'default', + details: `Logged out session for ${user.email}.`, + }, + }); + + return { success: true }; + } + + async me(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + name: true, + email: true, + status: true, + createdAt: true, + lastLoginAt: true, + }, + }); + + if (!user) { + throw new UnauthorizedException('User not found'); + } + + return user; + } + + async getCurrentSession(authUser: AuthenticatedUser, ipAddress?: string) { + const user = await this.prisma.user.findUnique({ + where: { id: authUser.sub }, + select: { + id: true, + name: true, + email: true, + status: true, + lastLoginAt: true, + refreshTokenExpiresAt: true, + twoFactorEnabled: true, + twoFactorConfirmedAt: true, + }, + }); + + if (!user) { + throw new UnauthorizedException('User not found'); + } + + return { + user: { + id: user.id, + name: user.name, + email: user.email, + status: user.status, + lastLoginAt: user.lastLoginAt, + twoFactorEnabled: user.twoFactorEnabled, + twoFactorConfirmedAt: user.twoFactorConfirmedAt, + }, + session: { + issuedAt: authUser.iat ? new Date(authUser.iat * 1000).toISOString() : null, + expiresAt: authUser.exp ? new Date(authUser.exp * 1000).toISOString() : null, + refreshExpiresAt: user.refreshTokenExpiresAt, + currentIp: ipAddress || null, + policy: 'single-session', + }, + }; + } + + async getTwoFactorStatus(userId: string) { + const twoFactor = await this.getTwoFactorRecord(userId); + if (!twoFactor) { + throw new UnauthorizedException('User not found'); + } + + return { + enabled: twoFactor.twoFactorEnabled, + pendingSetup: Boolean(twoFactor.twoFactorPendingSecretEncrypted), + confirmedAt: twoFactor.twoFactorConfirmedAt, + recoveryCodesRemaining: this.readRecoveryCodeHashes(twoFactor).length, + }; + } + + async initiateTwoFactorSetup(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, email: true }, + }); + + if (!user) { + throw new UnauthorizedException('User not found'); + } + + const secret = generateTotpSecret(); + await this.prisma.$executeRaw( + Prisma.sql`UPDATE "users" SET "two_factor_pending_secret_encrypted" = ${encryptSecret(secret, config.jwtSecret)} WHERE "id" = CAST(${user.id} AS uuid)`, + ); + + return { + manualEntryKey: secret, + otpauthUrl: buildOtpAuthUrl(secret, user.email, 'BizOne'), + }; + } + + async confirmTwoFactorSetup(userId: string, code: string, ipAddress?: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + name: true, + email: true, + }, + }); + + const twoFactor = user ? await this.getTwoFactorRecord(user.id) : null; + if (!user || !twoFactor?.twoFactorPendingSecretEncrypted) { + throw new UnauthorizedException('Two-factor setup is not initialized'); + } + + const secret = decryptSecret(twoFactor.twoFactorPendingSecretEncrypted, config.jwtSecret); + if (!verifyTotpCode(secret, code)) { + throw new UnauthorizedException('Invalid verification code'); + } + + const recoveryCodes = this.generateRecoveryCodes(); + const recoveryCodeHashes = recoveryCodes.map((value) => this.hashRecoveryCode(value)); + + await this.prisma.$executeRaw( + Prisma.sql` + UPDATE "users" + SET + "two_factor_enabled" = true, + "two_factor_secret_encrypted" = ${twoFactor.twoFactorPendingSecretEncrypted}, + "two_factor_pending_secret_encrypted" = null, + "two_factor_recovery_codes_hash_json" = ${JSON.stringify(recoveryCodeHashes)}::jsonb, + "two_factor_confirmed_at" = ${new Date()}, + "refresh_token_hash" = null, + "refresh_token_expires_at" = null, + "session_version" = "session_version" + 1 + WHERE "id" = CAST(${user.id} AS uuid) + `, + ); + + await this.prisma.auditLog.create({ + data: { + actorUserId: user.id, + actorName: user.name, + actorEmail: user.email, + actionType: 'Two Factor Enabled', + module: 'Auth Gateway', + ipAddress: ipAddress || null, + severity: 'default', + details: `Enabled 2FA for ${user.email}.`, + }, + }); + + await this.sendSecurityNotificationSafely({ + to: user.email, + name: user.name, + subject: 'BizOne security alert: 2FA enabled', + heading: 'Two-Factor Authentication Enabled', + intro: 'Two-factor authentication was enabled for your BizOne admin account.', + bullets: [ + `Account: ${user.email}`, + `Time: ${formatSecurityTimestamp(new Date())}`, + `IP address: ${ipAddress || 'Unavailable'}`, + ], + note: 'If you did not enable 2FA yourself, reset your password and contact an administrator immediately.', + }); + + return { + success: true, + recoveryCodes, + }; + } + + async disableTwoFactor(userId: string, code: string, ipAddress?: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + name: true, + email: true, + }, + }); + + const twoFactor = user ? await this.getTwoFactorRecord(user.id) : null; + if (!user || !twoFactor?.twoFactorEnabled || !twoFactor.twoFactorSecretEncrypted) { + throw new UnauthorizedException('Two-factor authentication is not enabled'); + } + + const secret = decryptSecret(twoFactor.twoFactorSecretEncrypted, config.jwtSecret); + if (!verifyTotpCode(secret, code)) { + throw new UnauthorizedException('Invalid verification code'); + } + + await this.prisma.$executeRaw( + Prisma.sql` + UPDATE "users" + SET + "two_factor_enabled" = false, + "two_factor_secret_encrypted" = null, + "two_factor_pending_secret_encrypted" = null, + "two_factor_recovery_codes_hash_json" = null, + "two_factor_confirmed_at" = null, + "refresh_token_hash" = null, + "refresh_token_expires_at" = null, + "session_version" = "session_version" + 1 + WHERE "id" = CAST(${user.id} AS uuid) + `, + ); + + await this.prisma.auditLog.create({ + data: { + actorUserId: user.id, + actorName: user.name, + actorEmail: user.email, + actionType: 'Two Factor Disabled', + module: 'Auth Gateway', + ipAddress: ipAddress || null, + severity: 'alert', + details: `Disabled 2FA for ${user.email}.`, + }, + }); + + await this.sendSecurityNotificationSafely({ + to: user.email, + name: user.name, + subject: 'BizOne security alert: 2FA disabled', + heading: 'Two-Factor Authentication Disabled', + intro: 'Two-factor authentication was disabled for your BizOne admin account.', + bullets: [ + `Account: ${user.email}`, + `Time: ${formatSecurityTimestamp(new Date())}`, + `IP address: ${ipAddress || 'Unavailable'}`, + ], + note: 'If this was not you, reset your password immediately and re-enable 2FA after regaining control.', + }); + + return { success: true }; + } + + async regenerateTwoFactorRecoveryCodes(userId: string, code: string, ipAddress?: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + name: true, + email: true, + }, + }); + + const twoFactor = user ? await this.getTwoFactorRecord(user.id) : null; + if (!user || !twoFactor?.twoFactorEnabled || !twoFactor.twoFactorSecretEncrypted) { + throw new UnauthorizedException('Two-factor authentication is not enabled'); + } + + const secret = decryptSecret(twoFactor.twoFactorSecretEncrypted, config.jwtSecret); + if (!verifyTotpCode(secret, code)) { + throw new UnauthorizedException('Invalid verification code'); + } + + const recoveryCodes = this.generateRecoveryCodes(); + const recoveryCodeHashes = recoveryCodes.map((value) => this.hashRecoveryCode(value)); + + await this.prisma.$executeRaw( + Prisma.sql` + UPDATE "users" + SET "two_factor_recovery_codes_hash_json" = ${JSON.stringify(recoveryCodeHashes)}::jsonb + WHERE "id" = CAST(${user.id} AS uuid) + `, + ); + + await this.prisma.auditLog.create({ + data: { + actorUserId: user.id, + actorName: user.name, + actorEmail: user.email, + actionType: 'Two Factor Recovery Codes Regenerated', + module: 'Auth Gateway', + ipAddress: ipAddress || null, + severity: 'alert', + details: `Regenerated 2FA recovery codes for ${user.email}.`, + }, + }); + + return { + success: true, + recoveryCodes, + }; + } + + async verifyTwoFactorLogin(challengeToken: string, code: string, ipAddress?: string) { + const payload = await this.jwtService.verifyAsync(challengeToken, { + secret: config.jwtSecret, + }); + + if (payload.purpose !== '2fa-login') { + throw new UnauthorizedException('Invalid challenge token'); + } + + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + select: { + id: true, + name: true, + email: true, + status: true, + sessionVersion: true, + }, + }); + + const twoFactor = user ? await this.getTwoFactorRecord(user.id) : null; + + if ( + !user || + user.status !== 'active' || + user.sessionVersion !== payload.ver || + user.email !== payload.email || + !twoFactor?.twoFactorEnabled || + !twoFactor.twoFactorSecretEncrypted + ) { + throw new UnauthorizedException('Invalid challenge token'); + } + + await this.assertRateLimit(TWO_FACTOR_LIMITER, user.email, ipAddress); + + const secret = decryptSecret(twoFactor.twoFactorSecretEncrypted, config.jwtSecret); + const normalizedCode = this.normalizeRecoveryCode(code); + const recoveryCodeHashes = this.readRecoveryCodeHashes(twoFactor); + const isTotpValid = verifyTotpCode(secret, code); + const matchedRecoveryCodeIndex = recoveryCodeHashes.findIndex( + (candidate) => candidate === this.hashRecoveryCode(normalizedCode), + ); + + if (!isTotpValid && matchedRecoveryCodeIndex === -1) { + await this.registerFailedAttempt(TWO_FACTOR_LIMITER, user.email, ipAddress); + + await this.prisma.auditLog.create({ + data: { + actorUserId: user.id, + actorName: user.name, + actorEmail: user.email, + actionType: 'Two Factor Login Failed', + module: 'Auth Gateway', + ipAddress: ipAddress || null, + severity: 'alert', + details: `Failed 2FA verification for ${user.email}.`, + }, + }); + throw new UnauthorizedException('Invalid verification code'); + } + + if (!isTotpValid && matchedRecoveryCodeIndex >= 0) { + recoveryCodeHashes.splice(matchedRecoveryCodeIndex, 1); + await this.prisma.$executeRaw( + Prisma.sql` + UPDATE "users" + SET "two_factor_recovery_codes_hash_json" = ${JSON.stringify(recoveryCodeHashes)}::jsonb + WHERE "id" = CAST(${user.id} AS uuid) + `, + ); + + await this.prisma.auditLog.create({ + data: { + actorUserId: user.id, + actorName: user.name, + actorEmail: user.email, + actionType: 'Two Factor Recovery Code Used', + module: 'Auth Gateway', + ipAddress: ipAddress || null, + severity: 'alert', + details: `Used a backup recovery code for ${user.email}.`, + }, + }); + + await this.sendSecurityNotificationSafely({ + to: user.email, + name: user.name, + subject: 'BizOne security alert: recovery code used', + heading: 'Backup Recovery Code Used', + intro: 'A backup recovery code was used to complete a BizOne admin login.', + bullets: [ + `Account: ${user.email}`, + `Time: ${formatSecurityTimestamp(new Date())}`, + `IP address: ${ipAddress || 'Unavailable'}`, + `Recovery codes remaining: ${recoveryCodeHashes.length}`, + ], + note: 'If this was not you, rotate your password and regenerate backup codes as soon as possible.', + }); + } + + await this.clearFailedAttempts(TWO_FACTOR_LIMITER, user.email, ipAddress); + + const session = await this.issueSession( + { + sub: user.id, + email: user.email, + ver: user.sessionVersion, + }, + user.id, + ); + + await this.prisma.auditLog.create({ + data: { + actorUserId: user.id, + actorName: user.name, + actorEmail: user.email, + actionType: 'Two Factor Login Success', + module: 'Auth Gateway', + ipAddress: ipAddress || null, + severity: 'default', + details: `Completed 2FA login for ${user.email}.`, + }, + }); + + await this.prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }); + + return { + ...session, + user: { + id: user.id, + name: user.name, + email: user.email, + status: user.status, + }, + recoveryCodesRemaining: recoveryCodeHashes.length, + }; + } + + async requestPasswordReset(email: string, ipAddress?: string) { + const normalizedEmail = normalizeEmail(email); + await this.assertRateLimit(PASSWORD_RESET_LIMITER, normalizedEmail, ipAddress); + + const user = await this.prisma.user.findUnique({ + where: { email: normalizedEmail }, + select: { + id: true, + name: true, + email: true, + status: true, + }, + }); + + if (!user || user.status !== 'active') { + await this.registerFailedAttempt(PASSWORD_RESET_LIMITER, normalizedEmail, ipAddress); + return { success: true }; + } + + const token = this.generateToken(); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + const tokenHash = this.hashToken(token); + const resetUrl = `${config.frontendOrigin}/reset-password/${token}`; + + await this.prisma.user.update({ + where: { id: user.id }, + data: { + passwordResetTokenHash: tokenHash, + passwordResetTokenExpiresAt: expiresAt, + }, + }); + + try { + await this.mailer.sendPasswordResetEmail({ + to: user.email, + name: user.name, + resetUrl, + }); + } catch { + // Keep the response generic to avoid account enumeration. + } + + await this.registerFailedAttempt(PASSWORD_RESET_LIMITER, normalizedEmail, ipAddress); + + await this.prisma.auditLog.create({ + data: { + actorUserId: user.id, + actorName: user.name, + actorEmail: user.email, + actionType: 'Password Reset Requested', + module: 'Auth Gateway', + ipAddress: ipAddress || null, + severity: 'default', + details: `Password reset requested for ${user.email}.`, + }, + }); + + await this.sendSecurityNotificationSafely({ + to: user.email, + name: user.name, + subject: 'BizOne security alert: password reset completed', + heading: 'Password Reset Completed', + intro: 'Your BizOne admin password was changed successfully.', + bullets: [ + `Account: ${user.email}`, + `Time: ${formatSecurityTimestamp(new Date())}`, + `IP address: ${ipAddress || 'Unavailable'}`, + ], + note: 'If you did not complete this password reset, secure the account immediately and contact an administrator.', + }); + + return { success: true }; + } + + async getPasswordResetToken(token: string) { + const user = await this.findPasswordResetUser(token); + return { + email: user.email, + name: user.name, + expiresAt: user.passwordResetTokenExpiresAt, + }; + } + + async completePasswordReset(token: string, password: string, ipAddress?: string) { + if (!hasMinimumPasswordLength(password)) { + throw new HttpException('Password must be at least 8 characters', HttpStatus.BAD_REQUEST); + } + + const user = await this.findPasswordResetUser(token); + const passwordHash = await hashPassword(password); + + await this.prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash, + passwordResetTokenHash: null, + passwordResetTokenExpiresAt: null, + refreshTokenHash: null, + refreshTokenExpiresAt: null, + sessionVersion: { + increment: 1, + }, + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: user.id, + actorName: user.name, + actorEmail: user.email, + actionType: 'Password Reset Completed', + module: 'Auth Gateway', + ipAddress: ipAddress || null, + severity: 'default', + details: `Password reset completed for ${user.email}.`, + }, + }); + + return { success: true }; + } + + private async assertRateLimit( + limiter: { scope: string; maxAttempts: number; windowMinutes: number; message: string }, + identity: string, + ipAddress?: string, + ) { + const retryAfterMs = Math.max( + await this.getRetryAfterMs(this.buildEmailKey(limiter.scope, identity), limiter.maxAttempts), + ipAddress ? await this.getRetryAfterMs(this.buildIpKey(limiter.scope, ipAddress), limiter.maxAttempts) : 0, + ); + + if (retryAfterMs > 0) { + const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)); + throw new HttpException( + `${limiter.message} Try again in ${retryAfterSeconds} seconds.`, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + } + + private async registerFailedAttempt( + limiter: { scope: string; windowMinutes: number }, + identity: string, + ipAddress?: string, + ) { + await this.bumpAttempt(this.buildEmailKey(limiter.scope, identity), limiter.windowMinutes); + if (ipAddress) { + await this.bumpAttempt(this.buildIpKey(limiter.scope, ipAddress), limiter.windowMinutes); + } + } + + private async clearFailedAttempts( + limiter: { scope: string }, + identity: string, + ipAddress?: string, + ) { + await this.redisQueueService.deleteKey(this.buildEmailKey(limiter.scope, identity)); + if (ipAddress) { + await this.redisQueueService.deleteKey(this.buildIpKey(limiter.scope, ipAddress)); + } + } + + private async bumpAttempt(key: string, windowMinutes: number) { + await this.redisQueueService.incrementCounter(key, windowMinutes * 60); + } + + private async getRetryAfterMs(key: string, maxAttempts: number) { + const count = await this.redisQueueService.getCounter(key); + if (count < maxAttempts) { + return 0; + } + + const ttlSeconds = await this.redisQueueService.getTtlSeconds(key); + if (ttlSeconds <= 0) { + return 0; + } + + return ttlSeconds * 1000; + } + + private buildEmailKey(scope: string, identity: string) { + return `auth:${scope}:email:${identity}`; + } + + private buildIpKey(scope: string, ipAddress: string) { + return `auth:${scope}:ip:${ipAddress}`; + } + + private async recordFailedLogin(input: { + userId?: string; + name?: string; + email: string; + ipAddress?: string; + reason: string; + }) { + await this.prisma.auditLog.create({ + data: { + actorUserId: input.userId || null, + actorName: input.name || input.email, + actorEmail: input.email, + actionType: 'Login Failed', + module: 'Auth Gateway', + ipAddress: input.ipAddress || null, + severity: 'alert', + details: `Failed login for ${input.email}. Reason: ${input.reason}.`, + metadataJson: { + reason: input.reason, + email: input.email, + ipAddress: input.ipAddress || null, + } as Prisma.InputJsonValue, + }, + }); + } + + private async findPasswordResetUser(token: string) { + const user = await this.prisma.user.findFirst({ + where: { + passwordResetTokenHash: this.hashToken(token), + }, + select: { + id: true, + name: true, + email: true, + status: true, + passwordResetTokenExpiresAt: true, + }, + }); + + if (!user || user.status !== 'active') { + throw new UnauthorizedException('Invalid or expired reset link'); + } + + if (!user.passwordResetTokenExpiresAt || user.passwordResetTokenExpiresAt.getTime() <= Date.now()) { + throw new UnauthorizedException('Invalid or expired reset link'); + } + + return user; + } + + private async issueSession(payload: AuthenticatedUser, userId: string) { + const access_token = await this.jwtService.signAsync(payload); + const refresh_token = await this.jwtService.signAsync(payload, { + secret: config.jwtRefreshSecret, + expiresIn: config.jwtRefreshExpiresIn, + }); + const refreshTokenExpiresAt = new Date(Date.now() + this.durationToMs(config.jwtRefreshExpiresIn)); + + await this.prisma.user.update({ + where: { id: userId }, + data: { + refreshTokenHash: this.hashToken(refresh_token), + refreshTokenExpiresAt, + }, + }); + + return { + access_token, + refresh_token, + expires_in: config.jwtExpiresIn, + refresh_expires_in: config.jwtRefreshExpiresIn, + access_token_max_age_seconds: Math.floor(this.durationToMs(config.jwtExpiresIn) / 1000), + refresh_token_max_age_seconds: Math.floor(this.durationToMs(config.jwtRefreshExpiresIn) / 1000), + }; + } + + private async verifyRefreshToken(token: string) { + return this.jwtService.verifyAsync(token, { + secret: config.jwtRefreshSecret, + }); + } + + private hashToken(token: string) { + return createHash('sha256').update(token).digest('hex'); + } + + private generateToken() { + return randomBytes(24).toString('hex'); + } + + private async getTwoFactorRecord(userId: string): Promise { + const rows = await this.prisma.$queryRaw( + Prisma.sql` + SELECT + "two_factor_enabled" as "twoFactorEnabled", + "two_factor_secret_encrypted" as "twoFactorSecretEncrypted", + "two_factor_pending_secret_encrypted" as "twoFactorPendingSecretEncrypted", + "two_factor_recovery_codes_hash_json" as "twoFactorRecoveryCodesHashJson", + "two_factor_confirmed_at" as "twoFactorConfirmedAt" + FROM "users" + WHERE "id" = CAST(${userId} AS uuid) + LIMIT 1 + `, + ); + + return rows[0] || null; + } + + private durationToMs(value: string) { + const normalized = value.trim().toLowerCase(); + const match = normalized.match(/^(\d+)(s|m|h|d)$/); + if (match) { + const amount = Number(match[1]); + const unit = match[2]; + if (unit === 's') return amount * 1000; + if (unit === 'm') return amount * 60 * 1000; + if (unit === 'h') return amount * 60 * 60 * 1000; + return amount * 24 * 60 * 60 * 1000; + } + + const numeric = Number(normalized); + if (Number.isFinite(numeric) && numeric > 0) { + return numeric * 1000; + } + + throw new Error(`Unsupported duration format: ${value}`); + } + + private generateRecoveryCodes(count = 8) { + return Array.from({ length: count }, () => randomBytes(4).toString('hex').toUpperCase().match(/.{1,4}/g)?.join('-') || ''); + } + + private normalizeRecoveryCode(value: string) { + return value.toUpperCase().replace(/[^A-Z0-9]/g, ''); + } + + private hashRecoveryCode(value: string) { + return createHash('sha256').update(this.normalizeRecoveryCode(value)).digest('hex'); + } + + private readRecoveryCodeHashes(twoFactor: Pick) { + const raw = twoFactor.twoFactorRecoveryCodesHashJson; + if (!Array.isArray(raw)) { + return []; + } + + return raw.filter((value): value is string => typeof value === 'string' && value.length > 0); + } + + private async sendSecurityNotificationSafely(input: { + to: string; + name: string; + subject: string; + heading: string; + intro: string; + bullets: string[]; + note?: string; + }) { + try { + await this.mailer.sendSecurityNotificationEmail(input); + } catch { + // Avoid breaking the auth flow if SMTP is down. + } + } +} diff --git a/backend/src/auth/auth.types.ts b/backend/src/auth/auth.types.ts new file mode 100644 index 0000000..383951b --- /dev/null +++ b/backend/src/auth/auth.types.ts @@ -0,0 +1,7 @@ +export type AuthenticatedUser = { + sub: string; + email: string; + ver: number; + iat?: number; + exp?: number; +}; diff --git a/backend/src/auth/dto/complete-password-reset.dto.ts b/backend/src/auth/dto/complete-password-reset.dto.ts new file mode 100644 index 0000000..caa41fb --- /dev/null +++ b/backend/src/auth/dto/complete-password-reset.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class CompletePasswordResetDto { + @IsString() + @MinLength(8) + password!: string; +} diff --git a/backend/src/auth/dto/login.dto.ts b/backend/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..bc495a5 --- /dev/null +++ b/backend/src/auth/dto/login.dto.ts @@ -0,0 +1,13 @@ +import { Transform } from 'class-transformer'; +import { IsEmail, IsString, MinLength } from 'class-validator'; + +export class LoginDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value)) + @IsEmail() + email!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(8) + password!: string; +} diff --git a/backend/src/auth/dto/logout.dto.ts b/backend/src/auth/dto/logout.dto.ts new file mode 100644 index 0000000..2626656 --- /dev/null +++ b/backend/src/auth/dto/logout.dto.ts @@ -0,0 +1,10 @@ +import { Transform } from 'class-transformer'; +import { IsOptional, IsString, MinLength } from 'class-validator'; + +export class LogoutDto { + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(16) + refreshToken?: string; +} diff --git a/backend/src/auth/dto/refresh-token.dto.ts b/backend/src/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..aa27611 --- /dev/null +++ b/backend/src/auth/dto/refresh-token.dto.ts @@ -0,0 +1,9 @@ +import { Transform } from 'class-transformer'; +import { IsString, MinLength } from 'class-validator'; + +export class RefreshTokenDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(16) + refreshToken!: string; +} diff --git a/backend/src/auth/dto/request-password-reset.dto.ts b/backend/src/auth/dto/request-password-reset.dto.ts new file mode 100644 index 0000000..f463e0d --- /dev/null +++ b/backend/src/auth/dto/request-password-reset.dto.ts @@ -0,0 +1,6 @@ +import { IsEmail } from 'class-validator'; + +export class RequestPasswordResetDto { + @IsEmail() + email!: string; +} diff --git a/backend/src/auth/dto/two-factor-code.dto.ts b/backend/src/auth/dto/two-factor-code.dto.ts new file mode 100644 index 0000000..8928380 --- /dev/null +++ b/backend/src/auth/dto/two-factor-code.dto.ts @@ -0,0 +1,7 @@ +import { IsString, Matches } from 'class-validator'; + +export class TwoFactorCodeDto { + @IsString() + @Matches(/^\d{6}$/) + code!: string; +} diff --git a/backend/src/auth/dto/verify-two-factor-login.dto.ts b/backend/src/auth/dto/verify-two-factor-login.dto.ts new file mode 100644 index 0000000..dc66d47 --- /dev/null +++ b/backend/src/auth/dto/verify-two-factor-login.dto.ts @@ -0,0 +1,10 @@ +import { IsString, Matches } from 'class-validator'; + +export class VerifyTwoFactorLoginDto { + @IsString() + challengeToken!: string; + + @IsString() + @Matches(/^(\d{6}|[A-Za-z0-9]{4}-?[A-Za-z0-9]{4})$/) + code!: string; +} diff --git a/backend/src/auth/totp.ts b/backend/src/auth/totp.ts new file mode 100644 index 0000000..fdee293 --- /dev/null +++ b/backend/src/auth/totp.ts @@ -0,0 +1,131 @@ +import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +function base32Encode(buffer: Buffer) { + let bits = 0; + let value = 0; + let output = ''; + + for (const byte of buffer) { + value = (value << 8) | byte; + bits += 8; + + while (bits >= 5) { + output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + + if (bits > 0) { + output += BASE32_ALPHABET[(value << (5 - bits)) & 31]; + } + + return output; +} + +function base32Decode(value: string) { + const normalized = value.toUpperCase().replace(/=+$/g, '').replace(/[^A-Z2-7]/g, ''); + let bits = 0; + let current = 0; + const output: number[] = []; + + for (const char of normalized) { + const index = BASE32_ALPHABET.indexOf(char); + if (index === -1) { + continue; + } + + current = (current << 5) | index; + bits += 5; + + if (bits >= 8) { + output.push((current >>> (bits - 8)) & 255); + bits -= 8; + } + } + + return Buffer.from(output); +} + +function generateHotp(secret: string, counter: number, digits = 6) { + const key = base32Decode(secret); + const buffer = Buffer.alloc(8); + buffer.writeUInt32BE(Math.floor(counter / 0x100000000), 0); + buffer.writeUInt32BE(counter >>> 0, 4); + + const hmac = createHmac('sha1', key).update(buffer).digest(); + const offset = hmac[hmac.length - 1] & 0x0f; + const binary = + ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff); + + const otp = binary % 10 ** digits; + return otp.toString().padStart(digits, '0'); +} + +export function generateTotpSecret() { + return base32Encode(randomBytes(20)); +} + +export function verifyTotpCode(secret: string, code: string, window = 1, timestamp = Date.now()) { + const normalized = code.replace(/\s+/g, ''); + if (!/^\d{6}$/.test(normalized)) { + return false; + } + + for (let offset = -window; offset <= window; offset += 1) { + const counter = Math.floor((timestamp + offset * 30_000) / 1000 / 30); + const candidate = generateHotp(secret, counter); + const left = Buffer.from(candidate); + const right = Buffer.from(normalized); + if (left.length === right.length && timingSafeEqual(left, right)) { + return true; + } + } + + return false; +} + +export function buildOtpAuthUrl(secret: string, email: string, issuer: string) { + const label = encodeURIComponent(`${issuer}:${email}`); + const params = new URLSearchParams({ + secret, + issuer, + algorithm: 'SHA1', + digits: '6', + period: '30', + }); + return `otpauth://totp/${label}?${params.toString()}`; +} + +function deriveEncryptionKey(secret: string) { + return createHash('sha256').update(`${secret}:totp`).digest(); +} + +export function encryptSecret(secret: string, masterSecret: string) { + const key = deriveEncryptionKey(masterSecret); + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${iv.toString('base64url')}.${tag.toString('base64url')}.${encrypted.toString('base64url')}`; +} + +export function decryptSecret(payload: string, masterSecret: string) { + const [ivPart, tagPart, encryptedPart] = payload.split('.'); + if (!ivPart || !tagPart || !encryptedPart) { + throw new Error('Invalid encrypted secret payload'); + } + + const key = deriveEncryptionKey(masterSecret); + const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivPart, 'base64url')); + decipher.setAuthTag(Buffer.from(tagPart, 'base64url')); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(encryptedPart, 'base64url')), + decipher.final(), + ]); + return decrypted.toString('utf8'); +} diff --git a/backend/src/campaigns/campaign-worker.service.ts b/backend/src/campaigns/campaign-worker.service.ts new file mode 100644 index 0000000..bf02b16 --- /dev/null +++ b/backend/src/campaigns/campaign-worker.service.ts @@ -0,0 +1,42 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import type { Job as BullJob, Worker } from 'bullmq'; +import { JobsService } from '../jobs/jobs.service'; +import { RedisQueueService } from '../jobs/redis-queue.service'; +import { CampaignsService } from './campaigns.service'; + +@Injectable() +export class CampaignWorkerService implements OnModuleInit, OnModuleDestroy { + private worker: Worker | null = null; + + constructor( + private readonly jobsService: JobsService, + private readonly redisQueueService: RedisQueueService, + private readonly campaignsService: CampaignsService, + ) {} + + onModuleInit() { + this.worker = this.redisQueueService.createWorker( + 'campaigns', + async (job: BullJob<{ dbJobId?: string }>) => { + const dbJobId = job.data?.dbJobId; + if (!dbJobId) { + throw new Error('Redis campaign job missing dbJobId'); + } + + const claimed = await this.jobsService.markProcessing(dbJobId); + if (!claimed) { + return; + } + + await this.campaignsService.processJob(dbJobId); + }, + ); + } + + async onModuleDestroy() { + if (this.worker) { + await this.worker.close(); + this.worker = null; + } + } +} diff --git a/backend/src/campaigns/campaigns.controller.ts b/backend/src/campaigns/campaigns.controller.ts new file mode 100644 index 0000000..606f5ab --- /dev/null +++ b/backend/src/campaigns/campaigns.controller.ts @@ -0,0 +1,103 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Req, Res, UseGuards } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { AuthGuard } from '../common/auth.guard'; +import { RequirePermission } from '../common/permission.decorator'; +import { PermissionGuard } from '../common/permission.guard'; +import { CreateCampaignDto } from './dto/create-campaign.dto'; +import { SendCampaignDto } from './dto/send-campaign.dto'; +import { UpdateCampaignDto } from './dto/update-campaign.dto'; +import { CampaignsService } from './campaigns.service'; + +@UseGuards(AuthGuard, PermissionGuard) +@Controller('campaigns') +export class CampaignsController { + constructor(private readonly campaignsService: CampaignsService) {} + + @Get() + @RequirePermission('campaigns', 'view') + findAll() { + return this.campaignsService.findAll(); + } + + @Post() + @RequirePermission('campaigns', 'edit') + create( + @Req() request: Request & { user: AuthenticatedUser }, + @Body() dto: CreateCampaignDto, + ) { + return this.campaignsService.create(dto, request.user, request.ip); + } + + @Get(':id') + @RequirePermission('campaigns', 'view') + findOne( + @Param('id') id: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.campaignsService.findOne( + id, + page ? Number(page) : 1, + limit ? Number(limit) : 5, + ); + } + + @Post(':id/duplicate') + @RequirePermission('campaigns', 'edit') + duplicate( + @Req() request: Request & { user: AuthenticatedUser }, + @Param('id') id: string, + ) { + return this.campaignsService.duplicate(id, request.user, request.ip); + } + + @Patch(':id') + @RequirePermission('campaigns', 'edit') + update( + @Req() request: Request & { user: AuthenticatedUser }, + @Param('id') id: string, + @Body() dto: UpdateCampaignDto, + ) { + return this.campaignsService.update(id, dto, request.user, request.ip); + } + + @Delete(':id') + @RequirePermission('campaigns', 'delete') + remove( + @Req() request: Request & { user: AuthenticatedUser }, + @Param('id') id: string, + ) { + return this.campaignsService.remove(id, request.user, request.ip); + } + + @Post(':id/send') + @RequirePermission('campaigns', 'manage') + send( + @Req() request: Request & { user: AuthenticatedUser }, + @Param('id') id: string, + @Body() dto: SendCampaignDto, + ) { + return this.campaignsService.send(id, dto, request.user, request.ip); + } + + @Get(':id/export') + @RequirePermission('campaigns', 'view') + async export( + @Req() request: Request & { user: AuthenticatedUser }, + @Res() response: Response, + @Param('id') id: string, + @Query('format') format?: string, + ) { + const result = await this.campaignsService.exportReport( + id, + format === 'xlsx' ? 'xlsx' : 'csv', + request.user, + request.ip, + ); + + response.setHeader('Content-Type', result.contentType); + response.setHeader('Content-Disposition', `attachment; filename="${result.fileName}"`); + response.send(result.buffer); + } +} diff --git a/backend/src/campaigns/campaigns.module.ts b/backend/src/campaigns/campaigns.module.ts new file mode 100644 index 0000000..9a9387e --- /dev/null +++ b/backend/src/campaigns/campaigns.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { JobsModule } from '../jobs/jobs.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { TemplatesModule } from '../templates/templates.module'; +import { CampaignWorkerService } from './campaign-worker.service'; +import { CampaignsController } from './campaigns.controller'; +import { CampaignsService } from './campaigns.service'; + +@Module({ + imports: [PrismaModule, AuthModule, JobsModule, TemplatesModule], + controllers: [CampaignsController], + providers: [CampaignsService, CampaignWorkerService], + exports: [CampaignsService], +}) +export class CampaignsModule {} diff --git a/backend/src/campaigns/campaigns.service.ts b/backend/src/campaigns/campaigns.service.ts new file mode 100644 index 0000000..915bcdd --- /dev/null +++ b/backend/src/campaigns/campaigns.service.ts @@ -0,0 +1,966 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import type { Campaign, CampaignRecipient, Prisma } from '@prisma/client'; +import { randomUUID } from 'node:crypto'; +import * as XLSX from 'xlsx'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { normalizeText } from '../common/normalize'; +import { JobsService } from '../jobs/jobs.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TemplatesService } from '../templates/templates.service'; +import { CreateCampaignDto } from './dto/create-campaign.dto'; +import { SendCampaignDto } from './dto/send-campaign.dto'; +import { UpdateCampaignDto } from './dto/update-campaign.dto'; + +type SeedCampaign = { + code: string; + name: string; + audienceLabel: string; + audienceGroup: string; + status: string; + totalRecipients: number; + deliveredCount: number; + readCount: number; + failedCount: number; + deliveryRate: number | null; + readRate: number | null; + sentAt?: Date; + scheduledAt?: Date; + templateName: string; + language: string; + messageTitle: string; + messageBody: string; + primaryButton: string; + secondaryButton: string; + bannerImageUrl: string; + recipients: Array<{ + phoneNumber: string; + status: string; + sentAt?: Date; + errorReason?: string | null; + deviceOs?: string | null; + }>; +}; + +const summerSaleSentAt = new Date('2024-07-15T09:45:00.000Z'); + +const seededCampaigns: SeedCampaign[] = [ + { + code: 'CAM-98231', + name: 'Summer Sale 2024', + audienceLabel: '45,200 recipients', + audienceGroup: 'Retail subscribers', + status: 'Sent', + totalRecipients: 48250, + deliveredCount: 47482, + readCount: 30987, + failedCount: 578, + deliveryRate: 98.4, + readRate: 64.2, + sentAt: summerSaleSentAt, + templateName: 'summer_promo_v2', + language: 'English (US)', + messageTitle: 'Hi {{name}}, ☀️', + messageBody: + 'Our Summer Sale is here! Get up to 40% OFF on all new arrivals. Use code SUMMER40 at checkout.', + primaryButton: 'Shop Collection', + secondaryButton: 'View Catalog', + bannerImageUrl: + 'https://lh3.googleusercontent.com/aida-public/AB6AXuDEStTHrI49NhOpgRMdXx3saVUtVNe9fBtTvDiMZMeuDcQNU8eJHfAxc5hS5M8ligofVNNpUi59-kOLD9peg5njH1bWmsrHGXIx7A37_pAFEfxEAGVbjVjWCD0mGWIHu4LIShS9yDlFmvznUPzlye_JNLPzs7S8LIULMi-bL7cP6qt-uzXKuoWUwj1sw0uq0UcJnkCb6Y-04pNG8iNd2MINCbLOSbmRyf8OSOe1b9-u-sA6p5Mq3CKRjP-Fvk0vk3ZKdritVLiB0U8', + recipients: [ + { phoneNumber: '+1 (555) 012-3456', status: 'Read', sentAt: new Date('2024-07-15T10:12:00.000Z'), deviceOs: 'Android' }, + { phoneNumber: '+1 (555) 012-7890', status: 'Delivered', sentAt: new Date('2024-07-15T09:48:00.000Z'), deviceOs: 'iOS' }, + { phoneNumber: '+1 (555) 013-1122', status: 'Failed', sentAt: new Date('2024-07-15T09:45:00.000Z'), errorReason: 'Policy Violation', deviceOs: 'Android' }, + { phoneNumber: '+1 (555) 014-3344', status: 'Read', sentAt: new Date('2024-07-15T10:05:00.000Z'), deviceOs: 'Android' }, + { phoneNumber: '+1 (555) 015-5566', status: 'Delivered', sentAt: new Date('2024-07-15T09:52:00.000Z'), deviceOs: 'iOS' }, + { phoneNumber: '+1 (555) 016-7788', status: 'Read', sentAt: new Date('2024-07-15T10:16:00.000Z'), deviceOs: 'Android' }, + { phoneNumber: '+1 (555) 017-8899', status: 'Delivered', sentAt: new Date('2024-07-15T10:22:00.000Z'), deviceOs: 'Android' }, + { phoneNumber: '+1 (555) 018-9911', status: 'Read', sentAt: new Date('2024-07-15T10:31:00.000Z'), deviceOs: 'iOS' }, + { phoneNumber: '+1 (555) 019-2200', status: 'Failed', sentAt: new Date('2024-07-15T10:42:00.000Z'), errorReason: 'Invalid Template Parameter', deviceOs: 'Android' }, + { phoneNumber: '+1 (555) 020-3311', status: 'Delivered', sentAt: new Date('2024-07-15T10:51:00.000Z'), deviceOs: 'Web/Desktop' }, + { phoneNumber: '+1 (555) 021-4422', status: 'Read', sentAt: new Date('2024-07-15T11:09:00.000Z'), deviceOs: 'Android' }, + { phoneNumber: '+1 (555) 022-5533', status: 'Read', sentAt: new Date('2024-07-15T11:18:00.000Z'), deviceOs: 'iOS' }, + ], + }, + { + code: 'CAM-98244', + name: 'Weekly Newsletter #42', + audienceLabel: 'VIP Customer List', + audienceGroup: 'High-value segment', + status: 'Scheduled', + totalRecipients: 18640, + deliveredCount: 0, + readCount: 0, + failedCount: 0, + deliveryRate: null, + readRate: null, + scheduledAt: new Date('2024-07-16T14:00:00.000Z'), + templateName: 'vip_newsletter_v42', + language: 'English (US)', + messageTitle: 'Your insider update is almost here', + messageBody: 'A curated digest for premium customers with fresh offers and product stories.', + primaryButton: 'Open Newsletter', + secondaryButton: 'Manage Preferences', + bannerImageUrl: + 'https://images.unsplash.com/photo-1520607162513-77705c0f0d4a?auto=format&fit=crop&w=800&q=80', + recipients: [], + }, + { + code: 'CAM-98255', + name: 'Product Launch Promo', + audienceLabel: 'New Leads Segment', + audienceGroup: 'Cold outreach', + status: 'Draft', + totalRecipients: 9300, + deliveredCount: 0, + readCount: 0, + failedCount: 0, + deliveryRate: 0, + readRate: 0, + templateName: 'launch_teaser_v1', + language: 'English (US)', + messageTitle: 'Be first to see the launch', + messageBody: 'A teaser sequence for high-intent prospects before the public product announcement.', + primaryButton: 'Join Waitlist', + secondaryButton: 'See Features', + bannerImageUrl: + 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=800&q=80', + recipients: [], + }, + { + code: 'CAM-98201', + name: 'Loyalty Program Update', + audienceLabel: 'Dormant Users', + audienceGroup: 'Reactivation list', + status: 'Failed', + totalRecipients: 60700, + deliveredCount: 27315, + readCount: 10844, + failedCount: 33385, + deliveryRate: 45, + readRate: 17.9, + sentAt: new Date('2024-07-10T08:30:00.000Z'), + templateName: 'loyalty_reactivation_v1', + language: 'English (US)', + messageTitle: 'We saved your rewards for you', + messageBody: 'A recovery campaign for inactive users with points reminder and welcome-back offer.', + primaryButton: 'Claim Rewards', + secondaryButton: 'Need Help', + bannerImageUrl: + 'https://images.unsplash.com/photo-1515169067868-5387ec356754?auto=format&fit=crop&w=800&q=80', + recipients: [], + }, +]; + +@Injectable() +export class CampaignsService { + constructor( + private readonly prisma: PrismaService, + private readonly jobsService: JobsService, + private readonly templatesService: TemplatesService, + ) {} + + async findAll() { + await this.ensureSeedData(); + + const campaigns = await this.prisma.campaign.findMany(); + campaigns.sort((left, right) => this.getSortScore(right) - this.getSortScore(left)); + + const totalMessages = campaigns.reduce((sum, campaign) => sum + campaign.totalRecipients, 0); + const averageDeliveryRate = this.average( + campaigns + .map((campaign) => campaign.deliveryRate) + .filter((value): value is number => value !== null), + ); + const scheduledCount = campaigns.filter((campaign) => campaign.status === 'Scheduled').length; + const failedDeliveries = campaigns.reduce((sum, campaign) => sum + campaign.failedCount, 0); + + return { + metrics: { + totalMessages, + averageDeliveryRate, + scheduledCount, + failedDeliveries, + }, + items: campaigns.map((campaign) => this.serializeCampaignRow(campaign)), + }; + } + + async findOne(id: string, page = 1, limit = 5) { + await this.ensureSeedData(); + + const take = Math.min(Math.max(limit, 1), 50); + const currentPage = Math.max(page, 1); + + const campaign = await this.prisma.campaign.findUnique({ + where: { id }, + }); + + if (!campaign) { + throw new NotFoundException('Campaign not found'); + } + + const recipientWhere: Prisma.CampaignRecipientWhereInput = { + campaignId: campaign.id, + }; + + const [recipients, totalRecipients, deviceBreakdown] = await Promise.all([ + this.prisma.campaignRecipient.findMany({ + where: recipientWhere, + orderBy: [{ sentAt: 'asc' }, { createdAt: 'asc' }], + skip: (currentPage - 1) * take, + take, + }), + this.prisma.campaignRecipient.count({ where: recipientWhere }), + this.prisma.campaignRecipient.groupBy({ + by: ['deviceOs'], + where: recipientWhere, + _count: { _all: true }, + }), + ]); + + return { + campaign: this.serializeCampaignDetail(campaign), + timeline: this.buildTimeline(campaign, recipients), + recipients: { + items: recipients.map((recipient) => this.serializeRecipient(recipient)), + total: totalRecipients, + page: currentPage, + pageSize: take, + totalPages: Math.max(1, Math.ceil(totalRecipients / take)), + }, + deviceBreakdown: this.buildDeviceBreakdown(deviceBreakdown, totalRecipients), + }; + } + + async create(dto: CreateCampaignDto, user: AuthenticatedUser, ipAddress?: string) { + await this.ensureSeedData(); + await this.templatesService.assertTemplateExistsByName(dto.templateName); + const actor = await this.findActor(user.sub, user.email); + const name = normalizeText(dto.name) || 'Untitled Campaign'; + const totalRecipients = Math.max(0, dto.totalRecipients); + const status = normalizeText(dto.status) || 'Draft'; + const campaign = await this.prisma.campaign.create({ + data: { + id: randomUUID(), + code: await this.generateCampaignCode(), + name, + audienceLabel: normalizeText(dto.audienceLabel) || 'Untitled Segment', + audienceGroup: normalizeText(dto.audienceGroup) || 'General audience', + status, + totalRecipients, + deliveredCount: 0, + readCount: 0, + failedCount: 0, + deliveryRate: status === 'Draft' ? 0 : null, + readRate: 0, + scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null, + templateName: normalizeText(dto.templateName) || 'new_campaign_template', + language: normalizeText(dto.language) || 'English (US)', + messageTitle: normalizeText(dto.messageTitle) || 'New campaign draft', + messageBody: normalizeText(dto.messageBody) || 'Add your WhatsApp message content here.', + primaryButton: normalizeText(dto.primaryButton) || 'Primary CTA', + secondaryButton: normalizeText(dto.secondaryButton) || 'Secondary CTA', + bannerImageUrl: + normalizeText(dto.bannerImageUrl) || + 'https://images.unsplash.com/photo-1520607162513-77705c0f0d4a?auto=format&fit=crop&w=800&q=80', + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + actionType: 'Campaign Created', + module: 'Campaigns', + ipAddress: ipAddress || null, + severity: 'default', + details: `Created campaign ${campaign.name} (${campaign.code}).`, + }, + }); + + return this.serializeCampaignRow(campaign); + } + + async duplicate(id: string, user: AuthenticatedUser, ipAddress?: string) { + await this.ensureSeedData(); + const actor = await this.findActor(user.sub, user.email); + const source = await this.prisma.campaign.findUnique({ + where: { id }, + include: { + recipients: true, + }, + }); + + if (!source) { + throw new NotFoundException('Campaign not found'); + } + + const duplicate = await this.prisma.campaign.create({ + data: { + id: randomUUID(), + code: await this.generateCampaignCode(), + name: `${source.name} Copy`, + audienceLabel: source.audienceLabel, + audienceGroup: source.audienceGroup, + status: 'Draft', + totalRecipients: source.totalRecipients, + deliveredCount: 0, + readCount: 0, + failedCount: 0, + deliveryRate: 0, + readRate: 0, + scheduledAt: null, + templateName: source.templateName, + language: source.language, + messageTitle: source.messageTitle, + messageBody: source.messageBody, + primaryButton: source.primaryButton, + secondaryButton: source.secondaryButton, + bannerImageUrl: source.bannerImageUrl, + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + actionType: 'Campaign Duplicated', + module: 'Campaigns', + ipAddress: ipAddress || null, + severity: 'default', + details: `Duplicated campaign ${source.name} into ${duplicate.name}.`, + }, + }); + + return { + id: duplicate.id, + code: duplicate.code, + name: duplicate.name, + status: duplicate.status, + }; + } + + async update(id: string, dto: UpdateCampaignDto, user: AuthenticatedUser, ipAddress?: string) { + await this.ensureSeedData(); + await this.templatesService.assertTemplateExistsByName(dto.templateName); + const actor = await this.findActor(user.sub, user.email); + const campaign = await this.prisma.campaign.update({ + where: { id }, + data: { + ...(dto.name !== undefined ? { name: normalizeText(dto.name) || 'Untitled Campaign' } : {}), + ...(dto.audienceLabel !== undefined ? { audienceLabel: normalizeText(dto.audienceLabel) || 'Untitled Segment' } : {}), + ...(dto.audienceGroup !== undefined ? { audienceGroup: normalizeText(dto.audienceGroup) || 'General audience' } : {}), + ...(dto.status !== undefined ? { status: normalizeText(dto.status) || 'Draft' } : {}), + ...(dto.totalRecipients !== undefined ? { totalRecipients: Math.max(0, dto.totalRecipients) } : {}), + ...(dto.scheduledAt !== undefined ? { scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null } : {}), + ...(dto.templateName !== undefined ? { templateName: normalizeText(dto.templateName) || 'new_campaign_template' } : {}), + ...(dto.language !== undefined ? { language: normalizeText(dto.language) || 'English (US)' } : {}), + ...(dto.messageTitle !== undefined ? { messageTitle: normalizeText(dto.messageTitle) || 'New campaign draft' } : {}), + ...(dto.messageBody !== undefined ? { messageBody: normalizeText(dto.messageBody) || 'Add your WhatsApp message content here.' } : {}), + ...(dto.primaryButton !== undefined ? { primaryButton: normalizeText(dto.primaryButton) || 'Primary CTA' } : {}), + ...(dto.secondaryButton !== undefined ? { secondaryButton: normalizeText(dto.secondaryButton) || 'Secondary CTA' } : {}), + ...(dto.bannerImageUrl !== undefined + ? { + bannerImageUrl: + normalizeText(dto.bannerImageUrl) || + 'https://images.unsplash.com/photo-1520607162513-77705c0f0d4a?auto=format&fit=crop&w=800&q=80', + } + : {}), + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + actionType: 'Campaign Updated', + module: 'Campaigns', + ipAddress: ipAddress || null, + severity: 'default', + details: `Updated campaign ${campaign.name} (${campaign.code}).`, + }, + }); + + return this.serializeCampaignRow(campaign); + } + + async remove(id: string, user: AuthenticatedUser, ipAddress?: string) { + await this.ensureSeedData(); + const actor = await this.findActor(user.sub, user.email); + const campaign = await this.prisma.campaign.delete({ + where: { id }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + actionType: 'Campaign Deleted', + module: 'Campaigns', + ipAddress: ipAddress || null, + severity: 'default', + details: `Deleted campaign ${campaign.name} (${campaign.code}).`, + }, + }); + + return { id: campaign.id, deleted: true }; + } + + async send(id: string, dto: SendCampaignDto, user: AuthenticatedUser, ipAddress?: string) { + await this.ensureSeedData(); + const actor = await this.findActor(user.sub, user.email); + const campaign = await this.prisma.campaign.findUnique({ where: { id } }); + + if (!campaign) { + throw new NotFoundException('Campaign not found'); + } + + const requestedMode = dto.mode === 'scheduled' ? 'scheduled' : 'now'; + const requestedScheduleAt = dto.scheduledAt + ? new Date(dto.scheduledAt) + : campaign.scheduledAt; + const shouldSchedule = + requestedMode === 'scheduled' || Boolean(requestedScheduleAt && requestedScheduleAt.getTime() > Date.now()); + const availableAt = + shouldSchedule && requestedScheduleAt && requestedScheduleAt.getTime() > Date.now() + ? requestedScheduleAt + : new Date(); + + await this.prisma.campaign.update({ + where: { id }, + data: { + status: shouldSchedule ? 'Scheduled' : 'Sending', + scheduledAt: shouldSchedule ? availableAt : null, + sentAt: shouldSchedule ? null : new Date(), + }, + }); + + const job = await this.jobsService.enqueue({ + queueName: 'campaigns', + jobType: 'campaign.dispatch', + payload: { campaignId: id }, + maxAttempts: 3, + availableAt, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + actionType: shouldSchedule ? 'Campaign Scheduled' : 'Campaign Send Queued', + module: 'Campaigns', + ipAddress: ipAddress || null, + severity: 'default', + details: shouldSchedule + ? `Scheduled campaign ${campaign.name} for ${availableAt.toISOString()}.` + : `Queued campaign ${campaign.name} for immediate dispatch.`, + metadataJson: { + campaignId: id, + jobId: job.id, + availableAt: availableAt.toISOString(), + } as Prisma.InputJsonValue, + }, + }); + + return { + id: campaign.id, + jobId: job.id, + status: shouldSchedule ? 'Scheduled' : 'Queued', + availableAt: availableAt.toISOString(), + }; + } + + async exportReport( + id: string, + format: 'csv' | 'xlsx', + user: AuthenticatedUser, + ipAddress?: string, + ) { + await this.ensureSeedData(); + const actor = await this.findActor(user.sub, user.email); + const campaign = await this.prisma.campaign.findUnique({ + where: { id }, + include: { + recipients: { + orderBy: [{ sentAt: 'asc' }, { createdAt: 'asc' }], + }, + }, + }); + + if (!campaign) { + throw new NotFoundException('Campaign not found'); + } + + const summaryRows = [ + ['Campaign Name', campaign.name], + ['Campaign Code', campaign.code], + ['Status', campaign.status], + ['Total Recipients', campaign.totalRecipients], + ['Delivered Count', campaign.deliveredCount], + ['Delivered Rate', campaign.deliveryRate ?? 0], + ['Read Count', campaign.readCount], + ['Read Rate', campaign.readRate ?? 0], + ['Failed Count', campaign.failedCount], + ['Template Name', campaign.templateName || ''], + ['Language', campaign.language || ''], + ]; + + const recipientRows = campaign.recipients.map((recipient) => ({ + phoneNumber: recipient.phoneNumber, + status: recipient.status, + timestamp: recipient.sentAt?.toISOString() || '', + errorReason: recipient.errorReason || '', + deviceOs: recipient.deviceOs || '', + })); + + const baseName = this.slugifyFileName(`${campaign.name}-${campaign.code}`); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + actionType: 'Campaign Report Exported', + module: 'Campaigns', + ipAddress: ipAddress || null, + severity: 'default', + details: `Exported ${format.toUpperCase()} report for campaign ${campaign.name}.`, + }, + }); + + if (format === 'csv') { + const lines = [ + ['Section', 'Key', 'Value'].join(','), + ...summaryRows.map(([key, value]) => ['Summary', key, value].map((cell) => `"${String(cell).replaceAll('"', '""')}"`).join(',')), + '', + ['Phone Number', 'Status', 'Timestamp', 'Error Reason', 'Device OS'].join(','), + ...recipientRows.map((row) => + [row.phoneNumber, row.status, row.timestamp, row.errorReason, row.deviceOs] + .map((cell) => `"${String(cell).replaceAll('"', '""')}"`) + .join(','), + ), + ].join('\n'); + + return { + fileName: `${baseName}.csv`, + contentType: 'text/csv; charset=utf-8', + buffer: Buffer.from(lines, 'utf8'), + }; + } + + const workbook = XLSX.utils.book_new(); + const summarySheet = XLSX.utils.aoa_to_sheet([['Metric', 'Value'], ...summaryRows]); + const recipientsSheet = XLSX.utils.json_to_sheet(recipientRows); + XLSX.utils.book_append_sheet(workbook, summarySheet, 'Summary'); + XLSX.utils.book_append_sheet(workbook, recipientsSheet, 'Recipients'); + + return { + fileName: `${baseName}.xlsx`, + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + buffer: XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }), + }; + } + + async processJob(jobId: string) { + const job = await this.jobsService.findById(jobId); + if (!job) { + return; + } + + try { + const payload = job.payloadJson as { campaignId?: string }; + const campaignId = payload?.campaignId; + if (!campaignId) { + throw new Error('Campaign job payload is missing campaignId'); + } + + await this.dispatchCampaign(campaignId); + await this.jobsService.complete(jobId); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown campaign processing error'; + if (job.attempts < job.maxAttempts) { + await this.jobsService.retry(jobId, message, 2000 * job.attempts); + } else { + await this.jobsService.fail(jobId, message); + } + } + } + + private async ensureSeedData() { + const existingCodes = new Set( + ( + await this.prisma.campaign.findMany({ + select: { code: true }, + }) + ).map((campaign) => campaign.code), + ); + + for (const seed of seededCampaigns) { + if (existingCodes.has(seed.code)) { + continue; + } + + await this.prisma.campaign.create({ + data: { + id: randomUUID(), + code: seed.code, + name: seed.name, + audienceLabel: seed.audienceLabel, + audienceGroup: seed.audienceGroup, + status: seed.status, + totalRecipients: seed.totalRecipients, + deliveredCount: seed.deliveredCount, + readCount: seed.readCount, + failedCount: seed.failedCount, + deliveryRate: seed.deliveryRate, + readRate: seed.readRate, + sentAt: seed.sentAt, + scheduledAt: seed.scheduledAt, + templateName: seed.templateName, + language: seed.language, + messageTitle: seed.messageTitle, + messageBody: seed.messageBody, + primaryButton: seed.primaryButton, + secondaryButton: seed.secondaryButton, + bannerImageUrl: seed.bannerImageUrl, + recipients: { + create: seed.recipients.map((recipient) => ({ + id: randomUUID(), + phoneNumber: recipient.phoneNumber, + status: recipient.status, + sentAt: recipient.sentAt, + errorReason: recipient.errorReason || null, + deviceOs: recipient.deviceOs || null, + })), + }, + }, + }); + } + } + + private serializeCampaignRow(campaign: Campaign) { + return { + id: campaign.id, + code: campaign.code, + name: campaign.name, + audience: campaign.audienceLabel, + audienceGroup: campaign.audienceGroup, + sent: campaign.totalRecipients.toLocaleString('en-US'), + opened: campaign.readCount.toLocaleString('en-US'), + status: campaign.status, + deliveryRate: campaign.deliveryRate, + dateLabel: this.getDateLabel(campaign), + timeLabel: this.getTimeLabel(campaign), + templateName: campaign.templateName, + }; + } + + private serializeCampaignDetail(campaign: Campaign) { + return { + id: campaign.id, + code: campaign.code, + name: campaign.name, + status: campaign.status, + initiatedAt: campaign.sentAt?.toISOString() || campaign.scheduledAt?.toISOString() || campaign.createdAt.toISOString(), + totalRecipients: campaign.totalRecipients, + deliveredCount: campaign.deliveredCount, + deliveredRate: campaign.deliveryRate || 0, + readCount: campaign.readCount, + readRate: campaign.readRate || 0, + failedCount: campaign.failedCount, + failedRate: + campaign.totalRecipients > 0 ? Number(((campaign.failedCount / campaign.totalRecipients) * 100).toFixed(1)) : 0, + templateName: campaign.templateName || '-', + language: campaign.language || '-', + messageTitle: campaign.messageTitle || '', + messageBody: campaign.messageBody || '', + primaryButton: campaign.primaryButton || '', + secondaryButton: campaign.secondaryButton || '', + bannerImageUrl: campaign.bannerImageUrl || '', + }; + } + + private serializeRecipient(recipient: CampaignRecipient) { + return { + id: recipient.id, + phoneNumber: recipient.phoneNumber, + status: recipient.status, + sentAt: recipient.sentAt?.toISOString() || null, + errorReason: recipient.errorReason || null, + deviceOs: recipient.deviceOs || null, + }; + } + + private buildTimeline(campaign: Campaign, recipients: CampaignRecipient[]) { + const latestActivity = recipients.reduce( + (latest, recipient) => + recipient.sentAt && recipient.sentAt.getTime() > latest.getTime() ? recipient.sentAt : latest, + campaign.sentAt || campaign.createdAt, + ); + const timelineEnd = new Date(latestActivity); + timelineEnd.setUTCMinutes(0, 0, 0); + timelineEnd.setUTCHours(timelineEnd.getUTCHours() + 1); + + const buckets = Array.from({ length: 12 }, (_, index) => { + const start = new Date(timelineEnd); + start.setUTCHours(timelineEnd.getUTCHours() - (11 - index) * 2); + const bucketEnd = new Date(start); + bucketEnd.setUTCHours(start.getUTCHours() + 2); + const count = recipients.filter((recipient) => { + if (!recipient.sentAt) { + return false; + } + + return recipient.sentAt >= start && recipient.sentAt < bucketEnd; + }).length; + + return { + label: start.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'UTC', + }), + count, + }; + }); + + const highest = Math.max(...buckets.map((bucket) => bucket.count), 1); + return buckets.map((bucket) => ({ + label: bucket.label, + count: bucket.count, + height: Math.max(10, Math.round((bucket.count / highest) * 100)), + })); + } + + private buildDeviceBreakdown( + breakdown: Array<{ deviceOs: string | null; _count: { _all: number } }>, + total: number, + ) { + const totalSafe = total || 1; + const normalized = ['Android', 'iOS', 'Web/Desktop'].map((label) => { + const match = breakdown.find((entry) => entry.deviceOs === label); + const count = match?._count._all || 0; + return { + label, + count, + percentage: Math.round((count / totalSafe) * 100), + }; + }); + + return normalized; + } + + private getDateLabel(campaign: Campaign) { + if (campaign.status === 'Scheduled' && campaign.scheduledAt) { + return 'In 2 hours'; + } + + if (!campaign.sentAt) { + return 'Not set'; + } + + return campaign.sentAt.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }); + } + + private getTimeLabel(campaign: Campaign) { + const value = campaign.status === 'Scheduled' ? campaign.scheduledAt : campaign.sentAt; + if (!value) { + return ''; + } + + return value.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: true, + timeZone: 'UTC', + }); + } + + private average(values: number[]) { + if (values.length === 0) { + return 0; + } + + return Number((values.reduce((sum, value) => sum + value, 0) / values.length).toFixed(1)); + } + + private getSortScore(campaign: Campaign) { + const statusWeight = + campaign.status === 'Sent' + ? 4000000000000 + : campaign.status === 'Scheduled' + ? 3000000000000 + : campaign.status === 'Draft' + ? 2000000000000 + : 1000000000000; + + const dateWeight = (campaign.sentAt || campaign.scheduledAt || campaign.createdAt).getTime(); + return statusWeight + dateWeight; + } + + private async findActor(userId: string, email: string) { + const actor = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, name: true, email: true }, + }); + + return { + id: actor?.id || userId, + name: actor?.name || email, + email: actor?.email || email, + }; + } + + private async generateCampaignCode() { + const latest = await this.prisma.campaign.findMany({ + select: { code: true }, + orderBy: { createdAt: 'desc' }, + take: 20, + }); + + const numbers = latest + .map((campaign) => Number(campaign.code.replace(/[^0-9]/g, ''))) + .filter((value) => Number.isFinite(value)); + const next = (numbers.length ? Math.max(...numbers) : 98200) + 1; + return `CAM-${next}`; + } + + private slugifyFileName(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + } + + private async dispatchCampaign(campaignId: string) { + const campaign = await this.prisma.campaign.findUnique({ + where: { id: campaignId }, + include: { + recipients: true, + }, + }); + + if (!campaign) { + throw new Error('Campaign not found'); + } + + let recipients = campaign.recipients; + if (recipients.length === 0) { + recipients = await this.generateRecipientsForCampaign(campaign); + } + + const processedRecipients = recipients.map((recipient, index) => { + const mod = index % 11; + const status = mod === 0 ? 'Failed' : mod % 3 === 0 ? 'Read' : 'Delivered'; + const sentAt = new Date(Date.now() + index * 60 * 1000); + return { + id: recipient.id, + status, + sentAt, + errorReason: status === 'Failed' ? 'Simulated Provider Rejection' : null, + deviceOs: status === 'Failed' + ? 'Android' + : index % 4 === 0 + ? 'iOS' + : index % 5 === 0 + ? 'Web/Desktop' + : 'Android', + }; + }); + + const deliveredCount = processedRecipients.filter((recipient) => recipient.status !== 'Failed').length; + const readCount = processedRecipients.filter((recipient) => recipient.status === 'Read').length; + const failedCount = processedRecipients.filter((recipient) => recipient.status === 'Failed').length; + const totalRecipients = processedRecipients.length; + const deliveryRate = totalRecipients > 0 ? Number(((deliveredCount / totalRecipients) * 100).toFixed(1)) : 0; + const readRate = totalRecipients > 0 ? Number(((readCount / totalRecipients) * 100).toFixed(1)) : 0; + + await this.prisma.$transaction(async (tx) => { + for (const recipient of processedRecipients) { + await tx.campaignRecipient.update({ + where: { id: recipient.id }, + data: { + status: recipient.status, + sentAt: recipient.sentAt, + errorReason: recipient.errorReason, + deviceOs: recipient.deviceOs, + }, + }); + } + + await tx.campaign.update({ + where: { id: campaign.id }, + data: { + status: 'Sent', + totalRecipients, + deliveredCount, + readCount, + failedCount, + deliveryRate, + readRate, + sentAt: new Date(), + scheduledAt: null, + }, + }); + + await tx.auditLog.create({ + data: { + actorUserId: null, + actorName: 'Campaign Worker', + actorEmail: null, + actionType: 'Campaign Delivered', + module: 'Campaigns', + severity: 'default', + details: `Processed campaign ${campaign.name} with ${totalRecipients} recipients.`, + }, + }); + }); + } + + private async generateRecipientsForCampaign(campaign: Campaign) { + const contacts = await this.prisma.contact.findMany({ + orderBy: { createdAt: 'asc' }, + take: Math.min(Math.max(campaign.totalRecipients, 1), 250), + }); + + const desiredCount = Math.min(Math.max(campaign.totalRecipients, 1), 250); + const rows = Array.from({ length: desiredCount }, (_, index) => { + const contact = contacts[index]; + const phoneNumber = contact?.phoneNumber || this.syntheticPhoneNumber(index); + + return { + id: randomUUID(), + campaignId: campaign.id, + phoneNumber, + status: 'Queued', + sentAt: null, + errorReason: null, + deviceOs: null, + }; + }); + + await this.prisma.campaignRecipient.createMany({ + data: rows, + }); + + return this.prisma.campaignRecipient.findMany({ + where: { campaignId: campaign.id }, + orderBy: { createdAt: 'asc' }, + }); + } + + private syntheticPhoneNumber(index: number) { + return `+1 (555) ${String(100 + Math.floor(index / 100)).padStart(3, '0')}-${String(1000 + (index % 1000)).padStart(4, '0')}`; + } +} diff --git a/backend/src/campaigns/dto/create-campaign.dto.ts b/backend/src/campaigns/dto/create-campaign.dto.ts new file mode 100644 index 0000000..12d5d8d --- /dev/null +++ b/backend/src/campaigns/dto/create-campaign.dto.ts @@ -0,0 +1,99 @@ +import { Transform } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class CreateCampaignDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + name!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + audienceLabel!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + audienceGroup!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsOptional() + @IsString() + status?: string; + + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(0) + @Max(1000000) + totalRecipients!: number; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + scheduledAt?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + templateName?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + language?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + messageTitle?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + messageBody?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + primaryButton?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + secondaryButton?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + bannerImageUrl?: string; +} diff --git a/backend/src/campaigns/dto/send-campaign.dto.ts b/backend/src/campaigns/dto/send-campaign.dto.ts new file mode 100644 index 0000000..2fafbdc --- /dev/null +++ b/backend/src/campaigns/dto/send-campaign.dto.ts @@ -0,0 +1,22 @@ +import { Transform } from 'class-transformer'; +import { IsOptional, IsString } from 'class-validator'; + +export class SendCampaignDto { + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + mode?: 'now' | 'scheduled'; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + scheduledAt?: string; +} diff --git a/backend/src/campaigns/dto/update-campaign.dto.ts b/backend/src/campaigns/dto/update-campaign.dto.ts new file mode 100644 index 0000000..0de6304 --- /dev/null +++ b/backend/src/campaigns/dto/update-campaign.dto.ts @@ -0,0 +1,106 @@ +import { Transform } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class UpdateCampaignDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsOptional() + @IsString() + name?: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsOptional() + @IsString() + audienceLabel?: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsOptional() + @IsString() + audienceGroup?: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsOptional() + @IsString() + status?: string; + + @Transform(({ value }) => { + if (value === undefined || value === null || value === '') return undefined; + return Number(value); + }) + @IsOptional() + @IsInt() + @Min(0) + @Max(1000000) + totalRecipients?: number; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + scheduledAt?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + templateName?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + language?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + messageTitle?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + messageBody?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + primaryButton?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + secondaryButton?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + bannerImageUrl?: string; +} diff --git a/backend/src/common/auth.guard.ts b/backend/src/common/auth.guard.ts new file mode 100644 index 0000000..573cc70 --- /dev/null +++ b/backend/src/common/auth.guard.ts @@ -0,0 +1,32 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import type { Request } from 'express'; +import { AuthService } from '../auth/auth.service'; +import { AuthenticatedUser } from '../auth/auth.types'; + +@Injectable() +export class AuthGuard implements CanActivate { + constructor(private readonly authService: AuthService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader) { + throw new UnauthorizedException('Missing authorization header'); + } + + const [scheme, token] = authHeader.split(' '); + if (scheme !== 'Bearer' || !token) { + throw new UnauthorizedException('Invalid authorization header'); + } + + try { + const user = await this.authService.verifyAccessToken(token); + (request as Request & { user: AuthenticatedUser }).user = user; + } catch { + throw new UnauthorizedException('Invalid or expired token'); + } + + return true; + } +} diff --git a/backend/src/common/normalize.ts b/backend/src/common/normalize.ts new file mode 100644 index 0000000..b81ad81 --- /dev/null +++ b/backend/src/common/normalize.ts @@ -0,0 +1,25 @@ +export function normalizeEmail(value: string) { + return value.trim().toLowerCase(); +} + +export function normalizeOptionalEmail(value?: string) { + if (!value) { + return undefined; + } + + const normalized = normalizeEmail(value); + return normalized || undefined; +} + +export function normalizeText(value?: string) { + if (!value) { + return undefined; + } + + const normalized = value.trim(); + return normalized || undefined; +} + +export function normalizePhoneNumber(value: string) { + return value.trim(); +} diff --git a/backend/src/common/password.ts b/backend/src/common/password.ts new file mode 100644 index 0000000..3d5f2f9 --- /dev/null +++ b/backend/src/common/password.ts @@ -0,0 +1,13 @@ +import * as bcrypt from 'bcryptjs'; + +export async function hashPassword(password: string) { + return bcrypt.hash(password, 10); +} + +export async function comparePassword(password: string, hash: string) { + return bcrypt.compare(password, hash); +} + +export function hasMinimumPasswordLength(password: string) { + return password.trim().length >= 8; +} diff --git a/backend/src/common/permission.decorator.ts b/backend/src/common/permission.decorator.ts new file mode 100644 index 0000000..51deebb --- /dev/null +++ b/backend/src/common/permission.decorator.ts @@ -0,0 +1,9 @@ +import { SetMetadata } from '@nestjs/common'; + +export const REQUIRED_PERMISSION_KEY = 'required_permission'; + +export type PermissionAction = 'view' | 'edit' | 'delete' | 'manage'; + +export function RequirePermission(module: string, action: PermissionAction) { + return SetMetadata(REQUIRED_PERMISSION_KEY, { module, action }); +} diff --git a/backend/src/common/permission.guard.ts b/backend/src/common/permission.guard.ts new file mode 100644 index 0000000..ff9fd08 --- /dev/null +++ b/backend/src/common/permission.guard.ts @@ -0,0 +1,124 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { Request } from 'express'; +import { PrismaService } from '../prisma/prisma.service'; +import type { AuthenticatedUser } from '../auth/auth.types'; +import { REQUIRED_PERMISSION_KEY, type PermissionAction } from './permission.decorator'; + +type PermissionRequirement = { + module: string; + action: PermissionAction; +}; + +type PermissionRow = { + id?: unknown; + values?: unknown; +}; + +const fallbackRolePermissions: Record>>> = { + admin: { + campaigns: { view: true, edit: true, delete: true, manage: true }, + templates: { view: true, edit: true, delete: true, manage: true }, + users: { view: true, edit: true, delete: true, manage: true }, + roles: { view: true, edit: true, delete: true, manage: true }, + contacts: { view: true, edit: true, delete: true, manage: true }, + conversations: { view: true, edit: true, delete: true, manage: true }, + analytics: { view: true, edit: true, delete: true, manage: true }, + settings: { view: true, edit: true, delete: true, manage: true }, + }, + editor: { + campaigns: { view: true, edit: true, delete: false, manage: false }, + templates: { view: true, edit: true, delete: false, manage: false }, + contacts: { view: true, edit: false, delete: false, manage: false }, + conversations: { view: true, edit: true, delete: false, manage: false }, + analytics: { view: true, edit: false, delete: false, manage: false }, + }, + agent: { + campaigns: { view: true, edit: false, delete: false, manage: false }, + templates: { view: true, edit: false, delete: false, manage: false }, + contacts: { view: true, edit: true, delete: false, manage: false }, + conversations: { view: true, edit: true, delete: false, manage: false }, + analytics: { view: true, edit: false, delete: false, manage: false }, + }, +}; + +@Injectable() +export class PermissionGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly prisma: PrismaService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requirement = this.reflector.getAllAndOverride( + REQUIRED_PERMISSION_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requirement) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const authUser = request.user; + if (!authUser?.sub) { + throw new ForbiddenException('Missing authenticated user context'); + } + + const user = await this.prisma.user.findUnique({ + where: { id: authUser.sub }, + select: { + role: { + select: { + key: true, + permissionsJson: true, + }, + }, + }, + }); + + if (!user?.role) { + throw new ForbiddenException('No role assigned to this account'); + } + + if (user.role.key === 'admin') { + return true; + } + + const allowed = + this.hasPermissionFromRoleJson(user.role.permissionsJson, requirement.module, requirement.action) || + this.hasFallbackPermission(user.role.key, requirement.module, requirement.action); + + if (!allowed) { + throw new ForbiddenException(`Missing permission: ${requirement.module}.${requirement.action}`); + } + + return true; + } + + private hasPermissionFromRoleJson( + permissionsJson: unknown, + module: string, + action: PermissionAction, + ) { + if (!Array.isArray(permissionsJson)) { + return false; + } + + const row = permissionsJson.find((item): item is PermissionRow => { + if (!item || typeof item !== 'object') return false; + return typeof (item as PermissionRow).id === 'string' && (item as PermissionRow).id === module; + }); + + if (!row || !row.values || typeof row.values !== 'object') { + return false; + } + + const value = (row.values as Record)[action]; + return value === true; + } + + private hasFallbackPermission(roleKey: string, module: string, action: PermissionAction) { + return fallbackRolePermissions[roleKey]?.[module]?.[action] === true; + } +} diff --git a/backend/src/common/prisma-exception.filter.ts b/backend/src/common/prisma-exception.filter.ts new file mode 100644 index 0000000..5d2b98e --- /dev/null +++ b/backend/src/common/prisma-exception.filter.ts @@ -0,0 +1,36 @@ +import { + ArgumentsHost, + Catch, + ConflictException, + ExceptionFilter, + HttpStatus, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import type { Response } from 'express'; + +@Catch(Prisma.PrismaClientKnownRequestError) +export class PrismaExceptionFilter implements ExceptionFilter { + catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) { + const response = host.switchToHttp().getResponse(); + + if (exception.code === 'P2002') { + const target = Array.isArray(exception.meta?.target) + ? exception.meta.target.join(', ') + : 'unique field'; + const error = new ConflictException(`Resource already exists for ${target}`); + return response.status(error.getStatus()).json(error.getResponse()); + } + + if (exception.code === 'P2025') { + const error = new NotFoundException('Resource not found'); + return response.status(error.getStatus()).json(error.getResponse()); + } + + return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: 'Database request failed', + error: 'Internal Server Error', + }); + } +} diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 0000000..1039ded --- /dev/null +++ b/backend/src/config/env.ts @@ -0,0 +1,270 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { config as loadDotEnv } from 'dotenv'; + +type AppConfig = { + databaseUrl: string; + jwtSecret: string; + jwtExpiresIn: string; + jwtRefreshSecret: string; + jwtRefreshExpiresIn: string; + port: number; + frontendOrigin: string; + corsOrigins: string[]; + publicApiUrl: string; + redisUrl: string; + webhookVerifyToken: string; + webhookSharedSecret: string; + metaWebhookAppSecret?: string; + webhookAllowUnsigned: boolean; + nodeEnv: string; + isProduction: boolean; + mailHost?: string; + mailPort: number; + mailSecure: boolean; + mailUser?: string; + mailPassword?: string; + mailFrom: string; + authLoginMaxAttempts: number; + authLoginWindowMinutes: number; + authTwoFactorMaxAttempts: number; + authTwoFactorWindowMinutes: number; + authPasswordResetMaxAttempts: number; + authPasswordResetWindowMinutes: number; +}; + +let cachedConfig: AppConfig | null = null; +const INSECURE_SECRET_PATTERNS = new Set([ + 'change-me', + 'change-me-webhook-token', + 'change-me-webhook-secret', + 'supersecretjwt', + 'password', + 'secret', +]); + +function loadEnvironmentFiles() { + const candidatePaths = [ + resolve(process.cwd(), '../.env'), + resolve(process.cwd(), '.env'), + resolve(process.cwd(), '../.env.local'), + resolve(process.cwd(), '.env.local'), + ]; + + for (const path of candidatePaths) { + if (existsSync(path)) { + loadDotEnv({ path, override: false, quiet: true }); + } + } +} + +function requireEnv(name: string) { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +function parsePort(value: string | undefined) { + const parsed = Number(value ?? '3001'); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid PORT value: ${value}`); + } + + return parsed; +} + +function parseNodeEnv(value: string | undefined) { + const normalized = value?.trim().toLowerCase() || 'development'; + if (normalized === 'development' || normalized === 'production' || normalized === 'test') { + return normalized; + } + + throw new Error(`Invalid NODE_ENV value: ${value}`); +} + +function parseBoolean(value: string | undefined, fallback: boolean) { + if (value === undefined) { + return fallback; + } + + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes'; +} + +function parseOptionalPort(value: string | undefined, fallback: number) { + if (!value?.trim()) { + return fallback; + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid port value: ${value}`); + } + + return parsed; +} + +function parsePositiveInt(name: string, value: string | undefined, fallback: number) { + if (!value?.trim()) { + return fallback; + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer`); + } + + return parsed; +} + +function parseUrl(name: string, value: string) { + try { + return new URL(value); + } catch { + throw new Error(`Invalid ${name} URL: ${value}`); + } +} + +function parseOrigins(value: string | undefined) { + const rawOrigins = value?.trim() || 'http://localhost:3000'; + const origins = rawOrigins + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); + + if (origins.length === 0) { + throw new Error('FRONTEND_ORIGIN must contain at least one origin'); + } + + origins.forEach((origin) => parseUrl('FRONTEND_ORIGIN', origin)); + return origins; +} + +function assertStrongSecret(name: string, value: string, isProduction: boolean) { + const minimumLength = isProduction ? 32 : 12; + if (value.length < minimumLength) { + throw new Error(`${name} must be at least ${minimumLength} characters long`); + } + + if (isProduction && INSECURE_SECRET_PATTERNS.has(value.toLowerCase())) { + throw new Error(`${name} uses an insecure placeholder value`); + } +} + +function assertProductionUrl(name: string, value: string, isProduction: boolean) { + const parsed = parseUrl(name, value); + if (!isProduction) { + return; + } + + if (parsed.protocol !== 'https:') { + throw new Error(`${name} must use https in production`); + } +} + +function assertProductionMailConfig(config: Pick, isProduction: boolean) { + if (!isProduction) { + return; + } + + const hasAnyMailConfig = Boolean(config.mailHost || config.mailUser || config.mailPassword); + if (!hasAnyMailConfig) { + return; + } + + if (!config.mailHost || !config.mailUser || !config.mailPassword) { + throw new Error('MAIL_HOST, MAIL_USER, and MAIL_PASSWORD must all be set together in production'); + } + + if (config.mailFrom.toLowerCase() === 'no-reply@bizone.id') { + throw new Error('MAIL_FROM must be set to a real sender address in production'); + } +} + +loadEnvironmentFiles(); + +export function getAppConfig(): AppConfig { + if (cachedConfig) { + return cachedConfig; + } + + const nodeEnv = parseNodeEnv(process.env.NODE_ENV); + const isProduction = nodeEnv === 'production'; + const corsOrigins = parseOrigins(process.env.FRONTEND_ORIGIN); + const frontendOrigin = corsOrigins[0]; + const port = parsePort(process.env.PORT); + const publicApiUrl = process.env.PUBLIC_API_URL?.trim() || `http://localhost:${port}`; + const jwtSecret = requireEnv('JWT_SECRET'); + const jwtRefreshSecret = requireEnv('JWT_REFRESH_SECRET'); + const webhookVerifyToken = process.env.WEBHOOK_VERIFY_TOKEN?.trim() || 'change-me-webhook-token'; + const webhookSharedSecret = process.env.WEBHOOK_SHARED_SECRET?.trim() || 'change-me-webhook-secret'; + const webhookAllowUnsigned = parseBoolean( + process.env.WEBHOOK_ALLOW_UNSIGNED, + nodeEnv !== 'production', + ); + + cachedConfig = { + databaseUrl: requireEnv('DATABASE_URL'), + jwtSecret, + jwtExpiresIn: process.env.JWT_EXPIRES_IN?.trim() || '1d', + jwtRefreshSecret, + jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN?.trim() || '30d', + port, + frontendOrigin, + corsOrigins, + publicApiUrl, + redisUrl: process.env.REDIS_URL?.trim() || 'redis://127.0.0.1:6379', + webhookVerifyToken, + webhookSharedSecret, + metaWebhookAppSecret: process.env.META_WEBHOOK_APP_SECRET?.trim() || undefined, + webhookAllowUnsigned, + nodeEnv, + isProduction, + mailHost: process.env.MAIL_HOST?.trim() || undefined, + mailPort: parseOptionalPort(process.env.MAIL_PORT, 465), + mailSecure: parseBoolean(process.env.MAIL_SECURE, true), + mailUser: process.env.MAIL_USER?.trim() || undefined, + mailPassword: process.env.MAIL_PASSWORD?.trim() || undefined, + mailFrom: process.env.MAIL_FROM?.trim() || process.env.MAIL_USER?.trim() || 'no-reply@bizone.id', + authLoginMaxAttempts: parsePositiveInt('AUTH_LOGIN_MAX_ATTEMPTS', process.env.AUTH_LOGIN_MAX_ATTEMPTS, 5), + authLoginWindowMinutes: parsePositiveInt('AUTH_LOGIN_WINDOW_MINUTES', process.env.AUTH_LOGIN_WINDOW_MINUTES, 15), + authTwoFactorMaxAttempts: parsePositiveInt( + 'AUTH_2FA_MAX_ATTEMPTS', + process.env.AUTH_2FA_MAX_ATTEMPTS, + 5, + ), + authTwoFactorWindowMinutes: parsePositiveInt( + 'AUTH_2FA_WINDOW_MINUTES', + process.env.AUTH_2FA_WINDOW_MINUTES, + 10, + ), + authPasswordResetMaxAttempts: parsePositiveInt( + 'AUTH_PASSWORD_RESET_MAX_ATTEMPTS', + process.env.AUTH_PASSWORD_RESET_MAX_ATTEMPTS, + 3, + ), + authPasswordResetWindowMinutes: parsePositiveInt( + 'AUTH_PASSWORD_RESET_WINDOW_MINUTES', + process.env.AUTH_PASSWORD_RESET_WINDOW_MINUTES, + 30, + ), + }; + + assertStrongSecret('JWT_SECRET', cachedConfig.jwtSecret, isProduction); + assertStrongSecret('JWT_REFRESH_SECRET', cachedConfig.jwtRefreshSecret, isProduction); + assertStrongSecret('WEBHOOK_VERIFY_TOKEN', cachedConfig.webhookVerifyToken, isProduction); + assertStrongSecret('WEBHOOK_SHARED_SECRET', cachedConfig.webhookSharedSecret, isProduction); + assertProductionUrl('PUBLIC_API_URL', cachedConfig.publicApiUrl, isProduction); + cachedConfig.corsOrigins.forEach((origin) => assertProductionUrl('FRONTEND_ORIGIN', origin, isProduction)); + + if (isProduction && cachedConfig.webhookAllowUnsigned) { + throw new Error('WEBHOOK_ALLOW_UNSIGNED cannot be enabled in production'); + } + + assertProductionMailConfig(cachedConfig, isProduction); + + return cachedConfig; +} diff --git a/backend/src/contacts/contacts.controller.ts b/backend/src/contacts/contacts.controller.ts new file mode 100644 index 0000000..4614eae --- /dev/null +++ b/backend/src/contacts/contacts.controller.ts @@ -0,0 +1,86 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Req, Res, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import type { Response } from 'express'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { ContactsService } from './contacts.service'; +import { CreateContactDto } from '../dto/create-contact.dto'; +import { UpdateContactDto } from '../dto/update-contact.dto'; +import { AuthGuard } from '../common/auth.guard'; + +@UseGuards(AuthGuard) +@Controller('contacts') +export class ContactsController { + constructor(private readonly contactsService: ContactsService) {} + + @Get() + findAll( + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('search') search?: string, + @Query('status') status?: string, + @Query('tag') tag?: string, + ) { + return this.contactsService.findAll({ + page: Number(page || '1'), + limit: Number(limit || '10'), + search, + status, + tag, + }); + } + + @Get('export') + async exportContacts( + @Req() request: Request & { user: AuthenticatedUser }, + @Res() response: Response, + @Query('search') search?: string, + @Query('status') status?: string, + @Query('tag') tag?: string, + ) { + const result = await this.contactsService.exportContacts( + { + page: 1, + limit: 100000, + search, + status, + tag, + }, + request.user, + request.ip, + ); + + response.setHeader('Content-Type', result.contentType); + response.setHeader('Content-Disposition', `attachment; filename="${result.fileName}"`); + response.send(result.buffer); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.contactsService.findOne(id); + } + + @Post() + create( + @Req() request: Request & { user: AuthenticatedUser }, + @Body() dto: CreateContactDto, + ) { + return this.contactsService.create(dto, request.user, request.ip); + } + + @Patch(':id') + update( + @Req() request: Request & { user: AuthenticatedUser }, + @Param('id') id: string, + @Body() dto: UpdateContactDto, + ) { + return this.contactsService.update(id, dto, request.user, request.ip); + } + + @Delete(':id') + remove( + @Req() request: Request & { user: AuthenticatedUser }, + @Param('id') id: string, + ) { + return this.contactsService.remove(id, request.user, request.ip); + } +} diff --git a/backend/src/contacts/contacts.module.ts b/backend/src/contacts/contacts.module.ts new file mode 100644 index 0000000..e4a4f8b --- /dev/null +++ b/backend/src/contacts/contacts.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { ContactsController } from './contacts.controller'; +import { ContactsService } from './contacts.service'; + +@Module({ + imports: [AuthModule], + controllers: [ContactsController], + providers: [ContactsService], +}) +export class ContactsModule {} diff --git a/backend/src/contacts/contacts.service.ts b/backend/src/contacts/contacts.service.ts new file mode 100644 index 0000000..2252b8f --- /dev/null +++ b/backend/src/contacts/contacts.service.ts @@ -0,0 +1,413 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { + normalizeOptionalEmail, + normalizePhoneNumber, + normalizeText, +} from '../common/normalize'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateContactDto } from '../dto/create-contact.dto'; +import { UpdateContactDto } from '../dto/update-contact.dto'; + +type ContactsQuery = { + page: number; + limit: number; + search?: string; + status?: string; + tag?: string; +}; + +@Injectable() +export class ContactsService { + constructor(private readonly prisma: PrismaService) {} + + async findAll(query: ContactsQuery) { + const contacts = await this.prisma.contact.findMany({ orderBy: { createdAt: 'desc' } }); + const enriched = await Promise.all(contacts.map((contact) => this.enrichContactRow(contact))); + const filtered = enriched.filter((contact) => { + const matchesSearch = !query.search + || [contact.name, contact.email, contact.phoneNumber, contact.company, contact.location] + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(query.search!.trim().toLowerCase())); + const matchesStatus = !query.status + || query.status === 'all' + || contact.status.toLowerCase() === query.status.trim().toLowerCase(); + const matchesTag = !query.tag + || query.tag === 'all' + || contact.tags.some((tag) => tag.toLowerCase() === query.tag!.trim().toLowerCase()); + + return matchesSearch && matchesStatus && matchesTag; + }); + + const pageSize = Math.min(Math.max(query.limit || 10, 1), 100); + const page = Math.max(query.page || 1, 1); + const total = filtered.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize); + const availableTags = Array.from(new Set(enriched.flatMap((contact) => contact.tags))).sort(); + + return { + items, + total, + page, + pageSize, + totalPages, + availableTags, + statusCounts: { + active: enriched.filter((item) => item.status === 'Active').length, + inactive: enriched.filter((item) => item.status === 'Inactive').length, + }, + }; + } + + async findOne(id: string) { + const contact = await this.prisma.contact.findUnique({ where: { id } }); + if (!contact) { + throw new NotFoundException('Contact not found'); + } + + const [row, webhookEvents, auditLogs] = await Promise.all([ + this.enrichContactRow(contact), + this.prisma.webhookEvent.findMany({ + where: { + OR: [ + { senderPhone: contact.phoneNumber }, + { recipientPhone: contact.phoneNumber }, + ], + }, + orderBy: { createdAt: 'desc' }, + take: 20, + }), + this.prisma.auditLog.findMany({ + where: { + module: 'Contacts', + OR: [ + { details: { contains: contact.phoneNumber, mode: 'insensitive' } }, + { details: { contains: contact.name, mode: 'insensitive' } }, + ], + }, + orderBy: { createdAt: 'desc' }, + take: 20, + }), + ]); + + const notes = this.buildNotes(contact); + const history = [ + ...webhookEvents.map((event) => ({ + id: event.id, + type: event.senderPhone === contact.phoneNumber ? 'inbound' : 'message', + title: event.senderPhone === contact.phoneNumber ? 'Inbound Message' : 'Message Sent', + at: event.createdAt.toISOString(), + summary: + event.senderPhone === contact.phoneNumber + ? `Inbound WhatsApp event ${event.eventType} received from this contact.` + : `Outbound WhatsApp event ${event.eventType} delivered to this contact.`, + status: event.processingStatus, + errorReason: event.processingNotes, + })), + ...auditLogs.map((log) => ({ + id: log.id, + type: log.actionType.toLowerCase().includes('deleted') + ? 'status' + : log.actionType.toLowerCase().includes('created') + ? 'tag' + : 'system', + title: log.actionType, + at: log.createdAt.toISOString(), + summary: log.details, + status: log.severity, + errorReason: null as string | null, + })), + ] + .sort((left, right) => new Date(right.at).getTime() - new Date(left.at).getTime()) + .slice(0, 20); + + return { + contact: { + ...row, + notes, + history, + }, + }; + } + + async exportContacts(query: ContactsQuery, user: AuthenticatedUser, ipAddress?: string) { + const actor = await this.prisma.user.findUnique({ + where: { id: user.sub }, + select: { id: true, name: true, email: true }, + }); + const result = await this.findAll(query); + const lines = [ + ['Name', 'Email', 'Phone Number', 'Company', 'Location', 'Status', 'Tags', 'Last Message'].join(','), + ...result.items.map((contact) => + [ + contact.name, + contact.email || '', + contact.phoneNumber, + contact.company || '', + contact.location, + contact.status, + contact.tags.join(' | '), + contact.lastMessageLabel, + ] + .map((cell) => `"${String(cell).replaceAll('"', '""')}"`) + .join(','), + ), + ].join('\n'); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor?.id || user.sub, + actorName: actor?.name || user.email, + actorEmail: actor?.email || user.email, + actionType: 'Contact Exported', + module: 'Contacts', + ipAddress: ipAddress || null, + severity: 'default', + details: `Exported ${result.total} contact rows.`, + }, + }); + + return { + fileName: 'contacts-directory.csv', + contentType: 'text/csv; charset=utf-8', + buffer: Buffer.from(lines, 'utf8'), + }; + } + + async create(dto: CreateContactDto, user: AuthenticatedUser, ipAddress?: string) { + const actor = await this.prisma.user.findUnique({ + where: { id: user.sub }, + select: { id: true, name: true, email: true }, + }); + const contact = await this.prisma.contact.create({ + data: { + name: normalizeText(dto.name)!, + phoneNumber: normalizePhoneNumber(dto.phoneNumber), + email: normalizeOptionalEmail(dto.email), + company: normalizeText(dto.company), + notes: normalizeText(dto.notes), + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor?.id || user.sub, + actorName: actor?.name || user.email, + actorEmail: actor?.email || user.email, + actionType: 'Contact Created', + module: 'Contacts', + ipAddress: ipAddress || null, + severity: 'default', + details: `Created contact ${contact.name} (${contact.phoneNumber}).`, + }, + }); + + return contact; + } + + async update(id: string, dto: UpdateContactDto, user: AuthenticatedUser, ipAddress?: string) { + const actor = await this.prisma.user.findUnique({ + where: { id: user.sub }, + select: { id: true, name: true, email: true }, + }); + const contact = await this.prisma.contact.update({ + where: { id }, + data: { + name: dto.name === undefined ? undefined : normalizeText(dto.name), + phoneNumber: + dto.phoneNumber === undefined ? undefined : normalizePhoneNumber(dto.phoneNumber), + email: dto.email === undefined ? undefined : normalizeOptionalEmail(dto.email), + company: dto.company === undefined ? undefined : normalizeText(dto.company), + notes: dto.notes === undefined ? undefined : normalizeText(dto.notes), + isBlacklisted: dto.isBlacklisted, + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor?.id || user.sub, + actorName: actor?.name || user.email, + actorEmail: actor?.email || user.email, + actionType: 'Contact Updated', + module: 'Contacts', + ipAddress: ipAddress || null, + severity: 'default', + details: `Updated contact ${contact.name} (${contact.phoneNumber}).`, + }, + }); + + return contact; + } + + async remove(id: string, user: AuthenticatedUser, ipAddress?: string) { + const actor = await this.prisma.user.findUnique({ + where: { id: user.sub }, + select: { id: true, name: true, email: true }, + }); + const contact = await this.prisma.contact.delete({ where: { id } }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor?.id || user.sub, + actorName: actor?.name || user.email, + actorEmail: actor?.email || user.email, + actionType: 'Contact Deleted', + module: 'Contacts', + ipAddress: ipAddress || null, + severity: 'default', + details: `Deleted contact ${contact.name} (${contact.phoneNumber}).`, + }, + }); + + return contact; + } + + private async enrichContactRow(contact: Awaited>) { + const latestWebhook = await this.prisma.webhookEvent.findFirst({ + where: { + OR: [ + { senderPhone: contact.phoneNumber }, + { recipientPhone: contact.phoneNumber }, + ], + }, + orderBy: { createdAt: 'desc' }, + }); + + const tags = this.deriveTags(contact); + return { + id: contact.id, + name: contact.name, + email: contact.email, + phoneNumber: contact.phoneNumber, + company: contact.company, + status: this.deriveStatus(contact), + tags, + location: this.deriveLocation(contact.phoneNumber, contact.email), + lastMessageAt: latestWebhook?.createdAt.toISOString() || null, + lastMessageLabel: latestWebhook + ? latestWebhook.createdAt.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }) + : contact.updatedAt.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }), + lastSeenLabel: this.relativeTime(latestWebhook?.createdAt || contact.updatedAt), + isBlacklisted: contact.isBlacklisted, + avatarInitials: this.getInitials(contact.name), + createdAt: contact.createdAt.toISOString(), + updatedAt: contact.updatedAt.toISOString(), + }; + } + + private deriveStatus(contact: { + isBlacklisted: boolean; + updatedAt: Date; + }) { + if (contact.isBlacklisted) { + return 'Inactive'; + } + + const inactiveThreshold = Date.now() - 1000 * 60 * 60 * 24 * 45; + return contact.updatedAt.getTime() >= inactiveThreshold ? 'Active' : 'Inactive'; + } + + private deriveTags(contact: { + company: string | null; + notes: string | null; + createdAt: Date; + email: string | null; + }) { + const haystack = `${contact.company || ''} ${contact.notes || ''} ${contact.email || ''}`.toLowerCase(); + const tags = new Set(); + + if (haystack.includes('vip') || haystack.includes('premium')) tags.add('VIP'); + if (haystack.includes('retail') || haystack.includes('shop') || haystack.includes('store')) tags.add('Retail'); + if (haystack.includes('support') || haystack.includes('help') || haystack.includes('issue')) tags.add('Support'); + if (haystack.includes('lead') || haystack.includes('prospect')) tags.add('Lead'); + if (haystack.includes('wholesale') || haystack.includes('logistics') || haystack.includes('distribution')) tags.add('Wholesale'); + if (contact.createdAt.getTime() >= Date.now() - 1000 * 60 * 60 * 24 * 30) tags.add('New'); + if (contact.company?.toLowerCase().includes('bizone')) tags.add('Partner'); + + if (tags.size === 0) { + tags.add('General'); + } + + return Array.from(tags); + } + + private deriveLocation(phoneNumber: string, email?: string | null) { + const value = phoneNumber.replace(/\s+/g, ''); + if (value.startsWith('+62') || value.startsWith('62')) return 'Jakarta, ID'; + if (value.startsWith('+65') || value.startsWith('65')) return 'Singapore, SG'; + if (value.startsWith('+1') || value.startsWith('1')) return 'San Francisco, US'; + if (email?.endsWith('.es')) return 'Madrid, ES'; + if (email?.endsWith('.net')) return 'London, UK'; + return 'Regional Contact'; + } + + private buildNotes(contact: { notes: string | null; company: string | null; createdAt: Date }) { + const items = (contact.notes || '') + .split(/\n+/) + .map((note) => note.trim()) + .filter(Boolean); + + if (items.length === 0) { + return [ + { + id: 'default-1', + author: 'System Admin', + dateLabel: contact.createdAt.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }), + body: contact.company + ? `Associated with ${contact.company}. Keep communication contextual to this account.` + : 'No private notes yet. Add a note to capture relationship context and preferences.', + emphasized: true, + }, + ]; + } + + return items.map((item, index) => ({ + id: `note-${index + 1}`, + author: index === 0 ? 'System Admin' : 'Ops Team', + dateLabel: contact.createdAt.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }), + body: item, + emphasized: index === 0, + })); + } + + private relativeTime(date: Date) { + const diffMs = Date.now() - date.getTime(); + const diffMinutes = Math.max(1, Math.round(diffMs / 60000)); + if (diffMinutes < 60) return `${diffMinutes} mins ago`; + const diffHours = Math.round(diffMinutes / 60); + if (diffHours < 24) return `${diffHours} hours ago`; + const diffDays = Math.round(diffHours / 24); + return `${diffDays} days ago`; + } + + private getInitials(name: string) { + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() || '') + .join(''); + } +} diff --git a/backend/src/conversations/conversations.controller.ts b/backend/src/conversations/conversations.controller.ts new file mode 100644 index 0000000..870ffd2 --- /dev/null +++ b/backend/src/conversations/conversations.controller.ts @@ -0,0 +1,39 @@ +import { Body, Controller, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { AuthGuard } from '../common/auth.guard'; +import { SendConversationMessageDto } from './dto/send-message.dto'; +import { ConversationsService } from './conversations.service'; + +@UseGuards(AuthGuard) +@Controller('conversations') +export class ConversationsController { + constructor(private readonly conversationsService: ConversationsService) {} + + @Get() + findAll(@Query('filter') filter?: string, @Query('search') search?: string) { + return this.conversationsService.findAll({ filter, search }); + } + + @Get(':contactId') + findOne(@Param('contactId') contactId: string) { + return this.conversationsService.findOne(contactId); + } + + @Post(':contactId/messages') + sendMessage( + @Req() request: Request & { user: AuthenticatedUser }, + @Param('contactId') contactId: string, + @Body() dto: SendConversationMessageDto, + ) { + return this.conversationsService.sendMessage(contactId, dto, request.user, request.ip); + } + + @Post(':contactId/assign') + assignToCurrentUser( + @Req() request: Request & { user: AuthenticatedUser }, + @Param('contactId') contactId: string, + ) { + return this.conversationsService.assignToCurrentUser(contactId, request.user, request.ip); + } +} diff --git a/backend/src/conversations/conversations.module.ts b/backend/src/conversations/conversations.module.ts new file mode 100644 index 0000000..d02a084 --- /dev/null +++ b/backend/src/conversations/conversations.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ConversationsController } from './conversations.controller'; +import { ConversationsService } from './conversations.service'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [ConversationsController], + providers: [ConversationsService], + exports: [ConversationsService], +}) +export class ConversationsModule {} diff --git a/backend/src/conversations/conversations.service.ts b/backend/src/conversations/conversations.service.ts new file mode 100644 index 0000000..f928cca --- /dev/null +++ b/backend/src/conversations/conversations.service.ts @@ -0,0 +1,487 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Contact } from '@prisma/client'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { normalizeText } from '../common/normalize'; +import { PrismaService } from '../prisma/prisma.service'; + +type ConversationFilter = 'all' | 'active' | 'pending'; + +@Injectable() +export class ConversationsService { + constructor(private readonly prisma: PrismaService) {} + + async findAll(query: { filter?: string; search?: string }) { + const contacts = await this.prisma.contact.findMany({ + orderBy: { createdAt: 'desc' }, + }); + + const items = await Promise.all(contacts.map((contact) => this.buildConversationSummary(contact))); + const normalizedFilter = this.normalizeFilter(query.filter); + const searchNeedle = query.search?.trim().toLowerCase(); + + return items + .filter((item) => { + const matchesFilter = + normalizedFilter === 'all' + ? true + : normalizedFilter === 'pending' + ? item.status === 'PENDING' + : item.status === 'ACTIVE' || item.status === 'NEW'; + + const matchesSearch = + !searchNeedle || + [item.name, item.email, item.phone, item.topic, item.snippet, item.location] + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(searchNeedle)); + + return matchesFilter && matchesSearch; + }) + .sort((left, right) => new Date(right.lastActivityAt).getTime() - new Date(left.lastActivityAt).getTime()); + } + + async findOne(contactId: string) { + const contact = await this.prisma.contact.findUnique({ + where: { id: contactId }, + }); + + if (!contact) { + throw new NotFoundException('Conversation not found'); + } + + await this.prisma.conversationMessage.updateMany({ + where: { + contactId, + direction: 'incoming', + readAt: null, + }, + data: { + readAt: new Date(), + }, + }); + + return this.buildConversationDetail(contact); + } + + async sendMessage(contactId: string, dto: { body: string }, user: AuthenticatedUser, ipAddress?: string) { + const contact = await this.prisma.contact.findUnique({ + where: { id: contactId }, + }); + + if (!contact) { + throw new NotFoundException('Conversation not found'); + } + + const actor = await this.prisma.user.findUnique({ + where: { id: user.sub }, + select: { id: true, name: true, email: true }, + }); + + const body = normalizeText(dto.body); + if (!body) { + throw new BadRequestException('Message body is required'); + } + + const providerSend = await this.sendViaConfiguredProvider(contact.phoneNumber, body); + const message = await this.prisma.conversationMessage.create({ + data: { + contactId, + direction: 'outgoing', + source: providerSend.provider, + status: providerSend.status, + body, + senderUserId: actor?.id || user.sub, + senderName: actor?.name || user.email, + externalMessageId: providerSend.externalMessageId, + }, + }); + + await this.prisma.contact.update({ + where: { id: contactId }, + data: { + assignedUserId: actor?.id || user.sub, + assignedUserName: actor?.name || user.email, + assignedAt: new Date(), + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor?.id || user.sub, + actorName: actor?.name || user.email, + actorEmail: actor?.email || user.email, + actionType: 'Conversation Reply Sent', + module: 'Conversations', + ipAddress: ipAddress || null, + severity: 'default', + details: `Sent reply to ${contact.name} (${contact.phoneNumber}) via ${providerSend.provider}.`, + }, + }); + + return { + success: true, + message: this.mapThreadMessage(message), + }; + } + + async assignToCurrentUser(contactId: string, user: AuthenticatedUser, ipAddress?: string) { + const actor = await this.prisma.user.findUnique({ + where: { id: user.sub }, + select: { id: true, name: true, email: true }, + }); + + const contact = await this.prisma.contact.findUnique({ + where: { id: contactId }, + }); + + if (!contact) { + throw new NotFoundException('Conversation not found'); + } + + await this.prisma.contact.update({ + where: { id: contactId }, + data: { + assignedUserId: actor?.id || user.sub, + assignedUserName: actor?.name || user.email, + assignedAt: new Date(), + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor?.id || user.sub, + actorName: actor?.name || user.email, + actorEmail: actor?.email || user.email, + actionType: 'Conversation Assigned', + module: 'Conversations', + ipAddress: ipAddress || null, + severity: 'default', + details: `Assigned conversation ${contact.name} (${contact.phoneNumber}) to ${actor?.name || user.email}.`, + }, + }); + + return { success: true }; + } + + async syncInboundFromWebhookEvent(input: { + webhookEventId: string; + contactId: string; + externalMessageId?: string | null; + body: string; + occurredAt: Date; + }) { + const normalizedBody = normalizeText(input.body) || 'Inbound message received.'; + await this.prisma.conversationMessage.upsert({ + where: { webhookEventId: input.webhookEventId }, + update: { + body: normalizedBody, + externalMessageId: input.externalMessageId || undefined, + occurredAt: input.occurredAt, + status: 'received', + }, + create: { + contactId: input.contactId, + direction: 'incoming', + messageType: 'text', + source: 'webhook', + status: 'received', + body: normalizedBody, + externalMessageId: input.externalMessageId || undefined, + webhookEventId: input.webhookEventId, + occurredAt: input.occurredAt, + }, + }); + } + + private async buildConversationSummary(contact: Contact) { + const [messages, webhooks] = await Promise.all([ + this.prisma.conversationMessage.findMany({ + where: { contactId: contact.id }, + orderBy: { occurredAt: 'desc' }, + take: 20, + }), + this.prisma.webhookEvent.findMany({ + where: { + OR: [{ senderPhone: contact.phoneNumber }, { recipientPhone: contact.phoneNumber }], + }, + orderBy: { createdAt: 'desc' }, + take: 10, + }), + ]); + + const latestMessage = messages[0] || null; + const latestWebhook = webhooks[0] || null; + const latestMessageAt = latestMessage?.occurredAt?.getTime() || 0; + const latestWebhookAt = latestWebhook?.createdAt?.getTime() || 0; + const lastActivityAt = new Date(Math.max(latestMessageAt, latestWebhookAt, contact.updatedAt.getTime())); + const latestSnippet = + latestMessageAt >= latestWebhookAt + ? latestMessage?.body + : this.extractWebhookSnippet(latestWebhook?.payloadJson) || latestWebhook?.eventType || 'No activity yet'; + const tags = this.deriveTags(contact); + const unreadCount = messages.filter((message) => message.direction === 'incoming' && !message.readAt).length; + const fallbackInboundUnread = + unreadCount === 0 && + latestWebhook?.eventType === 'message.inbound' && + latestWebhookAt > latestMessageAt; + const status = fallbackInboundUnread || unreadCount > 0 + ? 'NEW' + : lastActivityAt.getTime() >= Date.now() - 1000 * 60 * 60 * 24 + ? 'ACTIVE' + : 'PENDING'; + + return { + id: contact.id, + name: contact.name, + initials: this.getInitials(contact.name), + time: this.relativeTime(lastActivityAt), + status, + tone: status === 'PENDING' ? 'warning' : status === 'ACTIVE' ? 'success' : 'info', + topic: tags[0]?.toUpperCase() || 'GENERAL', + snippet: latestSnippet || 'No activity yet', + online: lastActivityAt.getTime() >= Date.now() - 1000 * 60 * 10, + location: this.deriveLocation(contact.phoneNumber, contact.email), + email: contact.email || 'N/A', + phone: contact.phoneNumber, + customerSince: `Customer since ${contact.createdAt.toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + timeZone: 'UTC', + })}`, + tags, + lastActivityAt: lastActivityAt.toISOString(), + unreadCount, + assignedAgentName: contact.assignedUserName || null, + }; + } + + private async buildConversationDetail(contact: Contact) { + const [messages, webhookEvents, auditLogs] = await Promise.all([ + this.prisma.conversationMessage.findMany({ + where: { contactId: contact.id }, + orderBy: { occurredAt: 'asc' }, + take: 200, + }), + this.prisma.webhookEvent.findMany({ + where: { + OR: [{ senderPhone: contact.phoneNumber }, { recipientPhone: contact.phoneNumber }], + }, + orderBy: { createdAt: 'desc' }, + take: 10, + }), + this.prisma.auditLog.findMany({ + where: { + OR: [ + { details: { contains: contact.phoneNumber, mode: 'insensitive' } }, + { details: { contains: contact.name, mode: 'insensitive' } }, + ], + }, + orderBy: { createdAt: 'desc' }, + take: 6, + }), + ]); + + const mirroredWebhookIds = new Set(messages.map((message) => message.webhookEventId).filter(Boolean)); + const fallbackInboundMessages = webhookEvents + .filter((event) => event.eventType === 'message.inbound' && !mirroredWebhookIds.has(event.eventId)) + .map((event) => ({ + id: `webhook-${event.eventId}`, + direction: 'incoming' as const, + body: this.extractWebhookSnippet(event.payloadJson) || 'Inbound message received.', + time: this.formatThreadTime(event.createdAt), + occurredAt: event.createdAt.toISOString(), + })); + + const threadMessages = [ + ...messages.map((message) => this.mapThreadMessage(message)), + ...fallbackInboundMessages, + ].sort((left, right) => new Date(left.occurredAt).getTime() - new Date(right.occurredAt).getTime()); + + const summary = await this.buildConversationSummary(contact); + + return { + ...summary, + messages: threadMessages.map(({ occurredAt: _occurredAt, ...message }) => message), + activity: this.buildRecentActivity(webhookEvents, auditLogs), + assignedAgentName: contact.assignedUserName || summary.assignedAgentName, + }; + } + + private mapThreadMessage(message: { + id: string; + direction: string; + body: string; + occurredAt: Date; + status?: string; + }) { + return { + id: message.id, + direction: message.direction === 'outgoing' ? 'outgoing' : 'incoming', + body: message.body, + time: this.formatThreadTime(message.occurredAt), + status: message.status || 'sent', + occurredAt: message.occurredAt.toISOString(), + }; + } + + private buildRecentActivity( + webhookEvents: Array<{ id: string; eventType: string; createdAt: Date; processingStatus: string }>, + auditLogs: Array<{ id: string; actionType: string; createdAt: Date; details: string; severity: string }>, + ) { + return [ + ...webhookEvents.map((event) => ({ + id: `webhook-${event.id}`, + title: event.eventType, + meta: `${event.processingStatus} • ${this.relativeTime(event.createdAt)}`, + tone: event.eventType === 'message.inbound' ? 'primary' : 'muted', + createdAt: event.createdAt.toISOString(), + })), + ...auditLogs.map((log) => ({ + id: `audit-${log.id}`, + title: log.actionType, + meta: `${log.severity} • ${this.relativeTime(log.createdAt)}`, + tone: log.severity === 'alert' ? 'primary' : 'muted', + createdAt: log.createdAt.toISOString(), + })), + ] + .sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime()) + .slice(0, 5) + .map(({ createdAt: _createdAt, ...item }) => item); + } + + private normalizeFilter(value?: string): ConversationFilter { + const normalized = value?.trim().toLowerCase(); + if (normalized === 'active' || normalized === 'pending') { + return normalized; + } + + return 'all'; + } + + private extractWebhookSnippet(payload: unknown) { + if (!payload || typeof payload !== 'object') { + return ''; + } + + const record = payload as Record; + const textRecord = + record.text && typeof record.text === 'object' && !Array.isArray(record.text) + ? (record.text as Record) + : null; + const interactiveRecord = + record.interactive && typeof record.interactive === 'object' && !Array.isArray(record.interactive) + ? (record.interactive as Record) + : null; + + return [ + typeof textRecord?.body === 'string' ? textRecord.body : null, + typeof record.body === 'string' ? record.body : null, + typeof record.caption === 'string' ? record.caption : null, + typeof interactiveRecord?.title === 'string' ? interactiveRecord.title : null, + typeof record.type === 'string' ? `[${record.type}]` : null, + ].find((value) => typeof value === 'string' && value.trim()) || ''; + } + + private deriveTags(contact: { company: string | null; notes: string | null; createdAt: Date; email: string | null }) { + const haystack = `${contact.company || ''} ${contact.notes || ''} ${contact.email || ''}`.toLowerCase(); + const tags = new Set(); + if (haystack.includes('vip') || haystack.includes('premium')) tags.add('Premium'); + if (haystack.includes('support') || haystack.includes('help') || haystack.includes('issue')) tags.add('Support'); + if (haystack.includes('lead') || haystack.includes('prospect')) tags.add('Lead'); + if (haystack.includes('retail') || haystack.includes('shop') || haystack.includes('store')) tags.add('Inquiry'); + if (contact.createdAt.getTime() >= Date.now() - 1000 * 60 * 60 * 24 * 30) tags.add('New'); + if (tags.size === 0) tags.add('General'); + return Array.from(tags); + } + + private deriveLocation(phoneNumber: string, email?: string | null) { + const value = phoneNumber.replace(/\s+/g, ''); + if (value.startsWith('+62') || value.startsWith('62')) return 'Jakarta, ID'; + if (value.startsWith('+65') || value.startsWith('65')) return 'Singapore, SG'; + if (value.startsWith('+1') || value.startsWith('1')) return 'San Francisco, CA'; + if (email?.endsWith('.es')) return 'Madrid, ES'; + if (email?.endsWith('.net')) return 'London, UK'; + return 'Regional Contact'; + } + + private relativeTime(date: Date) { + const diffMs = Date.now() - date.getTime(); + const diffMinutes = Math.max(1, Math.floor(diffMs / (1000 * 60))); + if (diffMinutes < 60) return `${diffMinutes}m ago`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } + + private formatThreadTime(date: Date) { + return new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }).format(date); + } + + private getInitials(name: string) { + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() || '') + .join(''); + } + + private async sendViaConfiguredProvider(phoneNumber: string, body: string) { + const stored = await this.prisma.integrationConfig.findUnique({ + where: { configKey: 'whatsapp' }, + }); + const configJson = (stored?.configJson as Record | null) ?? {}; + const provider = String(configJson.provider || stored?.provider || 'internal').toLowerCase(); + const accessToken = typeof configJson.accessToken === 'string' ? configJson.accessToken : ''; + const phoneNumberId = typeof configJson.phoneNumberId === 'string' ? configJson.phoneNumberId : ''; + + if (stored?.isEnabled && provider === 'meta' && accessToken && phoneNumberId) { + const response = await fetch(`https://graph.facebook.com/v20.0/${phoneNumberId}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: phoneNumber.replace(/\D+/g, ''), + type: 'text', + text: { + preview_url: false, + body, + }, + }), + }); + + const payload = (await response.json().catch(() => ({}))) as { + messages?: Array<{ id?: string }>; + }; + + if (!response.ok) { + return { + provider: 'meta', + status: 'failed', + externalMessageId: null, + }; + } + + return { + provider: 'meta', + status: 'queued', + externalMessageId: payload.messages?.[0]?.id || null, + }; + } + + return { + provider: 'internal', + status: 'sent', + externalMessageId: null, + }; + } +} diff --git a/backend/src/conversations/dto/send-message.dto.ts b/backend/src/conversations/dto/send-message.dto.ts new file mode 100644 index 0000000..8b2a143 --- /dev/null +++ b/backend/src/conversations/dto/send-message.dto.ts @@ -0,0 +1,8 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class SendConversationMessageDto { + @IsString() + @MinLength(1) + @MaxLength(4000) + body!: string; +} diff --git a/backend/src/dashboard/dashboard.controller.ts b/backend/src/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..bfc32f9 --- /dev/null +++ b/backend/src/dashboard/dashboard.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { DashboardService } from './dashboard.service'; +import { AuthGuard } from '../common/auth.guard'; + +@UseGuards(AuthGuard) +@Controller('dashboard') +export class DashboardController { + constructor(private readonly dashboardService: DashboardService) {} + + @Get('summary') + summary() { + return this.dashboardService.summary(); + } + + @Get('analytics-summary') + analyticsSummary() { + return this.dashboardService.analyticsSummary(); + } +} diff --git a/backend/src/dashboard/dashboard.module.ts b/backend/src/dashboard/dashboard.module.ts new file mode 100644 index 0000000..620e878 --- /dev/null +++ b/backend/src/dashboard/dashboard.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { DashboardController } from './dashboard.controller'; +import { DashboardService } from './dashboard.service'; + +@Module({ + imports: [AuthModule], + controllers: [DashboardController], + providers: [DashboardService], +}) +export class DashboardModule {} diff --git a/backend/src/dashboard/dashboard.service.ts b/backend/src/dashboard/dashboard.service.ts new file mode 100644 index 0000000..73d423e --- /dev/null +++ b/backend/src/dashboard/dashboard.service.ts @@ -0,0 +1,182 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class DashboardService { + constructor(private readonly prisma: PrismaService) {} + + async summary() { + const totalContacts = await this.prisma.contact.count(); + const totalWebhookEvents = await this.prisma.webhookEvent.count(); + const failedWebhookEvents = await this.prisma.webhookEvent.count({ + where: { processingStatus: 'failed' }, + }); + + return { + totalContacts, + totalWebhookEvents, + deliveredRate: 0, + webhookHealth: failedWebhookEvents > 0 ? 'degraded' : 'healthy', + }; + } + + async analyticsSummary() { + const now = new Date(); + const last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const lastHour = new Date(now.getTime() - 60 * 60 * 1000); + + const [ + totalJobs, + pendingJobs, + processingJobs, + failedJobs24h, + totalWebhookEvents, + verifiedWebhookEvents, + pendingWebhookEvents, + auditTrailTotal, + recentJobs, + recentWebhookEvents, + ] = await this.prisma.$transaction([ + this.prisma.job.count(), + this.prisma.job.count({ where: { status: 'queued' } }), + this.prisma.job.count({ where: { status: 'processing' } }), + this.prisma.job.count({ + where: { + status: 'failed', + updatedAt: { gte: last24Hours }, + }, + }), + this.prisma.webhookEvent.count(), + this.prisma.webhookEvent.count({ where: { verified: true } }), + this.prisma.webhookEvent.count({ + where: { + processingStatus: { not: 'processed' }, + }, + }), + this.prisma.auditLog.count(), + this.prisma.job.findMany({ + where: { + createdAt: { gte: lastHour }, + }, + select: { + queueName: true, + status: true, + attempts: true, + maxAttempts: true, + createdAt: true, + processedAt: true, + }, + }), + this.prisma.webhookEvent.findMany({ + where: { + createdAt: { gte: lastHour }, + }, + select: { + createdAt: true, + verified: true, + processingStatus: true, + }, + }), + ]); + + const workerGroups = recentJobs.reduce>((acc, job) => { + const key = `${job.queueName}-worker`; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); + + const workerHealth = Object.entries(workerGroups).map(([name, count]) => { + const load = Math.max(4, Math.min(100, count * 12)); + return { + name, + load, + tone: load >= 70 ? 'success' : 'warning', + }; + }); + + const latencySamples = recentJobs + .filter((job) => job.processedAt) + .map((job) => job.processedAt!.getTime() - job.createdAt.getTime()) + .filter((value) => Number.isFinite(value) && value >= 0); + + const averageLatencyMs = + latencySamples.length > 0 + ? Math.round(latencySamples.reduce((sum, value) => sum + value, 0) / latencySamples.length) + : 120; + + const processedLastHour = recentJobs.filter((job) => job.status === 'processed').length; + const webhookLastHour = recentWebhookEvents.length; + const verifiedWebhookRate = + totalWebhookEvents > 0 ? Math.round((verifiedWebhookEvents / totalWebhookEvents) * 100) : 0; + const throughputPerMinute = Math.max(1, Math.round((processedLastHour + webhookLastHour) / 60)); + const averageAttempts = + recentJobs.length > 0 + ? Math.round( + recentJobs.reduce((sum, job) => sum + Math.min(job.attempts, job.maxAttempts), 0) / + recentJobs.length, + ) + : 0; + + const databaseConnectionsEstimate = Math.max( + 1, + Math.min(200, recentJobs.length + recentWebhookEvents.length + Math.min(auditTrailTotal, 120)), + ); + const memoryUsageGbEstimate = Number( + (1.8 + (recentJobs.length + recentWebhookEvents.length + pendingWebhookEvents) * 0.07).toFixed(1), + ); + + const apiLatencyBars = [ + Math.max(4, Math.min(100, 32 + pendingJobs * 2)), + Math.max(4, Math.min(100, 42 + processingJobs * 8)), + Math.max(4, Math.min(100, 36 + failedJobs24h * 6)), + Math.max(4, Math.min(100, 56 + pendingWebhookEvents * 8)), + Math.max(4, Math.min(100, 28 + averageAttempts * 9)), + Math.max(4, Math.min(100, 22 + processingJobs * 7)), + Math.max(4, Math.min(100, 30 + verifiedWebhookRate)), + ]; + + const memoryBars = [ + Math.max(4, Math.min(100, 48 + pendingJobs * 3)), + Math.max(4, Math.min(100, 54 + processingJobs * 5)), + Math.max(4, Math.min(100, 58 + failedJobs24h * 4)), + Math.max(4, Math.min(100, 50 + pendingWebhookEvents * 7)), + Math.max(4, Math.min(100, 55 + verifiedWebhookRate)), + Math.max(4, Math.min(100, 62 + processingJobs * 4)), + Math.max(4, Math.min(100, 64 + averageAttempts * 5)), + ]; + + return { + generatedAt: now.toISOString(), + queue: { + pendingJobs, + processingJobs, + failedJobs24h, + }, + workers: workerHealth, + throughput: { + perMinute: throughputPerMinute, + verifiedWebhookRate, + jobsLastHour: recentJobs.length, + webhooksLastHour: webhookLastHour, + }, + metrics: { + apiLatencyMs: averageLatencyMs, + apiLatencyBars, + databaseConnectionsEstimate, + databaseUsagePercent: Math.max(4, Math.min(100, Math.round((databaseConnectionsEstimate / 200) * 100))), + memoryUsageGbEstimate, + memoryBars, + }, + totals: { + totalJobs, + totalWebhookEvents, + pendingWebhookEvents, + auditTrailTotal, + }, + health: { + status: 'ok', + database: 'ok', + }, + }; + } +} diff --git a/backend/src/dto/create-contact.dto.ts b/backend/src/dto/create-contact.dto.ts new file mode 100644 index 0000000..ed13b62 --- /dev/null +++ b/backend/src/dto/create-contact.dto.ts @@ -0,0 +1,50 @@ +import { Transform } from 'class-transformer'; +import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateContactDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @IsNotEmpty() + name!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @IsNotEmpty() + phoneNumber!: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') { + return value; + } + + const trimmed = value.trim().toLowerCase(); + return trimmed || undefined; + }) + @IsOptional() + @IsEmail() + email?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') { + return value; + } + + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + company?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') { + return value; + } + + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + notes?: string; +} diff --git a/backend/src/dto/update-contact.dto.ts b/backend/src/dto/update-contact.dto.ts new file mode 100644 index 0000000..e84ea7e --- /dev/null +++ b/backend/src/dto/update-contact.dto.ts @@ -0,0 +1,81 @@ +import { Transform } from 'class-transformer'; +import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class UpdateContactDto { + @Transform(({ value }) => { + if (typeof value !== 'string') { + return value; + } + + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + @IsNotEmpty() + name?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') { + return value; + } + + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + @IsNotEmpty() + phoneNumber?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') { + return value; + } + + const trimmed = value.trim().toLowerCase(); + return trimmed || undefined; + }) + @IsOptional() + @IsEmail() + email?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') { + return value; + } + + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + company?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') { + return value; + } + + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + notes?: string; + + @Transform(({ value }) => { + if (value === undefined || value === null || value === '') { + return undefined; + } + + if (typeof value === 'boolean') { + return value; + } + + return value === 'true' || value === 'on'; + }) + @IsOptional() + @IsBoolean() + isBlacklisted?: boolean; +} diff --git a/backend/src/health/health.controller.ts b/backend/src/health/health.controller.ts new file mode 100644 index 0000000..d7295b4 --- /dev/null +++ b/backend/src/health/health.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Controller('health') +export class HealthController { + constructor(private readonly prisma: PrismaService) {} + + @Get() + async check() { + try { + await this.prisma.user.count({ take: 1 }); + } catch { + throw new ServiceUnavailableException('Database is unavailable'); + } + + return { + status: 'ok', + service: 'wa-dashboard-backend', + database: 'ok', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/backend/src/health/health.module.ts b/backend/src/health/health.module.ts new file mode 100644 index 0000000..3c29d50 --- /dev/null +++ b/backend/src/health/health.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [PrismaModule], + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/backend/src/integrations/dto/test-whatsapp-config.dto.ts b/backend/src/integrations/dto/test-whatsapp-config.dto.ts new file mode 100644 index 0000000..24c62fe --- /dev/null +++ b/backend/src/integrations/dto/test-whatsapp-config.dto.ts @@ -0,0 +1,14 @@ +import { Transform } from 'class-transformer'; +import { IsIn, IsOptional, IsString } from 'class-validator'; + +export class TestWhatsappConfigDto { + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value)) + @IsIn(['meta', 'qontak', 'default']) + provider?: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + senderPhone?: string; +} diff --git a/backend/src/integrations/dto/update-whatsapp-config.dto.ts b/backend/src/integrations/dto/update-whatsapp-config.dto.ts new file mode 100644 index 0000000..5f729fb --- /dev/null +++ b/backend/src/integrations/dto/update-whatsapp-config.dto.ts @@ -0,0 +1,63 @@ +import { Transform } from 'class-transformer'; +import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { defaultWhatsappSubscriptions } from '../whatsapp-subscriptions'; + +export class UpdateWhatsappConfigDto { + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value)) + @IsIn(['meta', 'qontak', 'default']) + provider?: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @IsNotEmpty() + webhookVerifyToken?: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @IsNotEmpty() + sharedSecret?: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + appSecret?: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + accessToken?: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + phoneNumberId?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + isEnabled?: boolean; + + @IsOptional() + @Transform(({ value }) => { + if (Array.isArray(value)) { + return value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter(Boolean); + } + + if (typeof value === 'string' && value.trim()) { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + } + + return defaultWhatsappSubscriptions; + }) + @IsArray() + @IsIn(defaultWhatsappSubscriptions, { each: true }) + subscriptions?: string[]; +} diff --git a/backend/src/integrations/integrations.controller.ts b/backend/src/integrations/integrations.controller.ts new file mode 100644 index 0000000..b1e0c81 --- /dev/null +++ b/backend/src/integrations/integrations.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Get, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { IntegrationsService } from './integrations.service'; +import { AuthGuard } from '../common/auth.guard'; +import { TestWhatsappConfigDto } from './dto/test-whatsapp-config.dto'; +import { UpdateWhatsappConfigDto } from './dto/update-whatsapp-config.dto'; + +@UseGuards(AuthGuard) +@Controller('integrations') +export class IntegrationsController { + constructor(private readonly integrationsService: IntegrationsService) {} + + @Get('whatsapp') + getWhatsappSettings() { + return this.integrationsService.getWhatsappSettings(); + } + + @Patch('whatsapp') + updateWhatsappSettings( + @Req() request: Request & { user: AuthenticatedUser }, + @Body() dto: UpdateWhatsappConfigDto, + ) { + return this.integrationsService.updateWhatsappSettings(dto, request.user, request.ip); + } + + @Post('whatsapp/test') + testWhatsappSettings( + @Req() request: Request & { user: AuthenticatedUser }, + @Body() dto: TestWhatsappConfigDto, + ) { + return this.integrationsService.testWhatsappSettings(dto, request.user, request.ip); + } +} diff --git a/backend/src/integrations/integrations.module.ts b/backend/src/integrations/integrations.module.ts new file mode 100644 index 0000000..b9c477d --- /dev/null +++ b/backend/src/integrations/integrations.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { IntegrationsController } from './integrations.controller'; +import { IntegrationsService } from './integrations.service'; + +@Module({ + imports: [AuthModule], + controllers: [IntegrationsController], + providers: [IntegrationsService], +}) +export class IntegrationsModule {} diff --git a/backend/src/integrations/integrations.service.ts b/backend/src/integrations/integrations.service.ts new file mode 100644 index 0000000..5f96e4a --- /dev/null +++ b/backend/src/integrations/integrations.service.ts @@ -0,0 +1,171 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { getAppConfig } from '../config/env'; +import { JobsService } from '../jobs/jobs.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TestWhatsappConfigDto } from './dto/test-whatsapp-config.dto'; +import { UpdateWhatsappConfigDto } from './dto/update-whatsapp-config.dto'; +import { + defaultWhatsappSubscriptions, + whatsappSubscriptionOptions, +} from './whatsapp-subscriptions'; + +@Injectable() +export class IntegrationsService { + constructor( + private readonly prisma: PrismaService, + private readonly jobsService: JobsService, + ) {} + + getWhatsappSettings() { + const config = getAppConfig(); + return this.prisma.integrationConfig + .findUnique({ + where: { configKey: 'whatsapp' }, + }) + .then((storedConfig) => { + const configJson = (storedConfig?.configJson as Record | null) ?? {}; + + return { + provider: String(configJson.provider || storedConfig?.provider || 'meta'), + webhookUrl: `${config.publicApiUrl.replace(/\/$/, '')}/api/webhooks/whatsapp`, + verifyToken: String(configJson.webhookVerifyToken || config.webhookVerifyToken), + status: storedConfig ? (storedConfig.isEnabled ? 'configured' : 'disabled') : 'not-configured', + hasSharedSecret: Boolean(configJson.sharedSecret || config.webhookSharedSecret), + hasAppSecret: Boolean(configJson.appSecret || config.metaWebhookAppSecret), + hasAccessToken: Boolean(configJson.accessToken), + phoneNumberId: typeof configJson.phoneNumberId === 'string' ? configJson.phoneNumberId : '', + isEnabled: storedConfig?.isEnabled ?? true, + subscriptions: Array.isArray(configJson.subscriptions) + ? configJson.subscriptions + : defaultWhatsappSubscriptions, + availableSubscriptions: whatsappSubscriptionOptions, + }; + }); + } + + async updateWhatsappSettings( + dto: UpdateWhatsappConfigDto, + user: AuthenticatedUser, + ipAddress?: string, + ) { + const actor = await this.prisma.user.findUnique({ + where: { id: user.sub }, + select: { id: true, name: true, email: true }, + }); + const existing = await this.prisma.integrationConfig.findUnique({ + where: { configKey: 'whatsapp' }, + }); + const previous = (existing?.configJson as Record | null) ?? {}; + const nextConfig = { + ...previous, + ...(dto.provider ? { provider: dto.provider } : {}), + ...(dto.webhookVerifyToken ? { webhookVerifyToken: dto.webhookVerifyToken } : {}), + ...(dto.sharedSecret ? { sharedSecret: dto.sharedSecret } : {}), + ...(dto.appSecret !== undefined ? { appSecret: dto.appSecret } : {}), + ...(dto.accessToken ? { accessToken: dto.accessToken } : {}), + ...(dto.phoneNumberId ? { phoneNumberId: dto.phoneNumberId } : {}), + ...(dto.subscriptions ? { subscriptions: dto.subscriptions } : {}), + }; + + const result = await this.prisma.integrationConfig.upsert({ + where: { configKey: 'whatsapp' }, + update: { + provider: dto.provider || existing?.provider || 'meta', + isEnabled: dto.isEnabled ?? existing?.isEnabled ?? true, + configJson: nextConfig as Prisma.InputJsonValue, + }, + create: { + configKey: 'whatsapp', + provider: dto.provider || 'meta', + isEnabled: dto.isEnabled ?? true, + configJson: nextConfig as Prisma.InputJsonValue, + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor?.id || user.sub, + actorName: actor?.name || user.email, + actorEmail: actor?.email || user.email, + actionType: 'WhatsApp Settings Updated', + module: 'Integrations', + ipAddress: ipAddress || null, + severity: 'default', + details: `Updated WhatsApp settings for provider ${result.provider}.`, + metadataJson: nextConfig as Prisma.InputJsonValue, + }, + }); + + return result; + } + + async testWhatsappSettings( + dto: TestWhatsappConfigDto, + user: AuthenticatedUser, + ipAddress?: string, + ) { + const actor = await this.prisma.user.findUnique({ + where: { id: user.sub }, + select: { id: true, name: true, email: true }, + }); + const provider = dto.provider || 'meta'; + const senderPhone = dto.senderPhone || '6281999000111'; + const eventId = `evt_test_${Date.now()}`; + + await this.prisma.webhookEvent.create({ + data: { + provider, + eventId, + eventType: 'message.inbound', + senderPhone, + recipientPhone: 'test-webhook-destination', + externalMessageId: eventId, + eventTimestamp: new Date(), + payloadJson: { + source: 'integration-test', + eventId, + provider, + senderPhone, + } as Prisma.InputJsonValue, + verified: true, + processingStatus: 'queued', + processingNotes: 'Queued from integration test endpoint', + }, + }); + + const job = await this.jobsService.enqueue({ + queueName: 'webhooks', + jobType: 'webhook.process', + payload: { eventId }, + maxAttempts: 1, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor?.id || user.sub, + actorName: actor?.name || user.email, + actorEmail: actor?.email || user.email, + actionType: 'Webhook Test Queued', + module: 'Integrations', + ipAddress: ipAddress || null, + severity: 'default', + details: `Queued webhook test ${eventId} for provider ${provider}.`, + metadataJson: { + provider, + eventId, + senderPhone, + jobId: job.id, + } as Prisma.InputJsonValue, + }, + }); + + return { + provider, + eventId, + jobId: job.id, + status: 'queued', + }; + } +} diff --git a/backend/src/integrations/whatsapp-subscriptions.ts b/backend/src/integrations/whatsapp-subscriptions.ts new file mode 100644 index 0000000..b6a924a --- /dev/null +++ b/backend/src/integrations/whatsapp-subscriptions.ts @@ -0,0 +1,41 @@ +export const whatsappSubscriptionOptions = [ + { + key: 'message.inbound', + label: 'messages', + description: 'Inbound customer messages', + }, + { + key: 'message.delivered', + label: 'message_deliveries', + description: 'Delivery status updates', + }, + { + key: 'message.read', + label: 'message_read', + description: 'Read receipt updates', + }, + { + key: 'account.updated', + label: 'account_update', + description: 'WhatsApp account health or policy changes', + }, + { + key: 'template.updated', + label: 'template_category_update', + description: 'Template review or category updates', + }, + { + key: 'message.failed', + label: 'message_failed', + description: 'Failed message status updates', + }, + { + key: 'message.sent', + label: 'message_sent', + description: 'Outbound sent acknowledgements', + }, +] as const; + +export const defaultWhatsappSubscriptions = whatsappSubscriptionOptions.map((item) => item.key); + +export type WhatsappSubscriptionKey = (typeof whatsappSubscriptionOptions)[number]['key']; diff --git a/backend/src/jobs/jobs.module.ts b/backend/src/jobs/jobs.module.ts new file mode 100644 index 0000000..f1f2af2 --- /dev/null +++ b/backend/src/jobs/jobs.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { JobsService } from './jobs.service'; +import { RedisQueueService } from './redis-queue.service'; + +@Global() +@Module({ + providers: [JobsService, RedisQueueService], + exports: [JobsService, RedisQueueService], +}) +export class JobsModule {} diff --git a/backend/src/jobs/jobs.service.ts b/backend/src/jobs/jobs.service.ts new file mode 100644 index 0000000..06e442e --- /dev/null +++ b/backend/src/jobs/jobs.service.ts @@ -0,0 +1,129 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { RedisQueueService } from './redis-queue.service'; + +type EnqueueJobInput = { + queueName: string; + jobType: string; + payload: Prisma.InputJsonValue; + maxAttempts?: number; + availableAt?: Date; +}; + +@Injectable() +export class JobsService { + constructor( + private readonly prisma: PrismaService, + private readonly redisQueueService: RedisQueueService, + ) {} + + async enqueue(input: EnqueueJobInput) { + const job = await this.prisma.job.create({ + data: { + queueName: input.queueName, + jobType: input.jobType, + payloadJson: input.payload, + maxAttempts: input.maxAttempts ?? 3, + availableAt: input.availableAt ?? new Date(), + }, + }); + + await this.redisQueueService.enqueue( + input.queueName, + input.jobType, + { dbJobId: job.id }, + { + jobId: job.id, + delay: Math.max((job.availableAt.getTime() - Date.now()), 0), + attempts: input.maxAttempts ?? 3, + removeOnComplete: 500, + removeOnFail: 500, + }, + ); + + return job; + } + + async markProcessing(id: string) { + const claim = await this.prisma.job.updateMany({ + where: { + id, + status: { in: ['queued', 'processing'] }, + }, + data: { + status: 'processing', + attempts: { increment: 1 }, + }, + }); + + if (claim.count === 0) { + return null; + } + + return this.prisma.job.findUnique({ where: { id } }); + } + + findById(id: string) { + return this.prisma.job.findUnique({ where: { id } }); + } + + complete(id: string) { + return this.prisma.job.update({ + where: { id }, + data: { + status: 'processed', + processedAt: new Date(), + errorMessage: null, + }, + }); + } + + async retry(id: string, errorMessage: string, delayMs: number) { + const job = await this.prisma.job.update({ + where: { id }, + data: { + status: 'queued', + availableAt: new Date(Date.now() + delayMs), + errorMessage: errorMessage.slice(0, 500), + }, + }); + + await this.redisQueueService.enqueue( + job.queueName, + job.jobType, + { dbJobId: job.id }, + { + jobId: `${job.id}:retry:${job.attempts}`, + delay: Math.max(delayMs, 0), + attempts: Math.max(job.maxAttempts - job.attempts, 1), + removeOnComplete: 500, + removeOnFail: 500, + }, + ); + + return job; + } + + fail(id: string, errorMessage: string) { + return this.prisma.job.update({ + where: { id }, + data: { + status: 'failed', + failedAt: new Date(), + errorMessage: errorMessage.slice(0, 500), + }, + }); + } + + findAll(limit = 50, queueName?: string, status?: string) { + return this.prisma.job.findMany({ + where: { + queueName: queueName?.trim() || undefined, + status: status?.trim() || undefined, + }, + orderBy: [{ createdAt: 'desc' }], + take: Math.min(Math.max(limit, 1), 100), + }); + } +} diff --git a/backend/src/jobs/redis-queue.service.ts b/backend/src/jobs/redis-queue.service.ts new file mode 100644 index 0000000..df4f08d --- /dev/null +++ b/backend/src/jobs/redis-queue.service.ts @@ -0,0 +1,69 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import { Queue, Worker, type JobsOptions, type Processor } from 'bullmq'; +import IORedis from 'ioredis'; +import { getAppConfig } from '../config/env'; + +@Injectable() +export class RedisQueueService implements OnModuleDestroy { + private readonly connection = new IORedis(getAppConfig().redisUrl, { + maxRetriesPerRequest: null, + }); + private readonly queues = new Map(); + private readonly workers: Worker[] = []; + + getQueue(name: string) { + const existing = this.queues.get(name); + if (existing) { + return existing; + } + + const queue = new Queue(name, { + connection: this.connection, + }); + this.queues.set(name, queue); + return queue; + } + + async enqueue(name: string, jobName: string, data: Record, options?: JobsOptions) { + const queue = this.getQueue(name); + await queue.add(jobName, data, options); + } + + async getCounter(key: string) { + const value = await this.connection.get(key); + return value ? Number(value) : 0; + } + + async getTtlSeconds(key: string) { + const ttl = await this.connection.ttl(key); + return ttl > 0 ? ttl : 0; + } + + async incrementCounter(key: string, ttlSeconds: number) { + const nextCount = await this.connection.incr(key); + if (nextCount === 1) { + await this.connection.expire(key, ttlSeconds); + } + + return nextCount; + } + + async deleteKey(key: string) { + await this.connection.del(key); + } + + createWorker(name: string, processor: Processor) { + const worker = new Worker(name, processor, { + connection: this.connection, + concurrency: 5, + }); + this.workers.push(worker); + return worker; + } + + async onModuleDestroy() { + await Promise.all(this.workers.map((worker) => worker.close())); + await Promise.all(Array.from(this.queues.values()).map((queue) => queue.close())); + await this.connection.quit(); + } +} diff --git a/backend/src/logs/dto/create-audit-log.dto.ts b/backend/src/logs/dto/create-audit-log.dto.ts new file mode 100644 index 0000000..35bdf7a --- /dev/null +++ b/backend/src/logs/dto/create-audit-log.dto.ts @@ -0,0 +1,20 @@ +import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateAuditLogDto { + @IsString() + @IsNotEmpty() + actionType!: string; + + @IsString() + @IsNotEmpty() + module!: string; + + @IsString() + @IsNotEmpty() + details!: string; + + @IsOptional() + @IsString() + @IsIn(['default', 'alert']) + severity?: 'default' | 'alert'; +} diff --git a/backend/src/logs/logs.controller.ts b/backend/src/logs/logs.controller.ts new file mode 100644 index 0000000..bde2bf1 --- /dev/null +++ b/backend/src/logs/logs.controller.ts @@ -0,0 +1,95 @@ +import { Body, Controller, Get, Post, Query, Req, Res, UseGuards } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { AuthGuard } from '../common/auth.guard'; +import { CreateAuditLogDto } from './dto/create-audit-log.dto'; +import { LogsService } from './logs.service'; + +@UseGuards(AuthGuard) +@Controller('logs') +export class LogsController { + constructor(private readonly logsService: LogsService) {} + + @Get('webhooks') + getWebhookLogs( + @Query('limit') limit?: string, + @Query('provider') provider?: string, + @Query('status') status?: string, + ) { + const parsedLimit = limit ? Number(limit) : 50; + return this.logsService.getWebhookLogs( + Number.isFinite(parsedLimit) ? parsedLimit : 50, + provider, + status, + ); + } + + @Get('jobs') + getJobLogs( + @Query('limit') limit?: string, + @Query('queue') queueName?: string, + @Query('status') status?: string, + ) { + const parsedLimit = limit ? Number(limit) : 50; + return this.logsService.getJobLogs( + Number.isFinite(parsedLimit) ? parsedLimit : 50, + queueName, + status, + ); + } + + @Get('audit-trail') + getAuditTrail( + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('range') range?: string, + @Query('user') actorName?: string, + @Query('actionType') actionType?: string, + @Query('module') moduleName?: string, + @Query('search') search?: string, + ) { + const parsedPage = page ? Number(page) : 1; + const parsedLimit = limit ? Number(limit) : 200; + return this.logsService.getAuditTrail( + Number.isFinite(parsedPage) ? parsedPage : 1, + Number.isFinite(parsedLimit) ? parsedLimit : 200, + range, + actorName, + actionType, + moduleName, + search, + ); + } + + @Get('audit-trail/export') + async exportAuditTrail( + @Res() response: Response, + @Query('range') range?: string, + @Query('user') actorName?: string, + @Query('actionType') actionType?: string, + @Query('module') moduleName?: string, + @Query('search') search?: string, + ) { + const csv = await this.logsService.exportAuditTrailCsv( + range, + actorName, + actionType, + moduleName, + search, + ); + + response.setHeader('Content-Type', 'text/csv; charset=utf-8'); + response.setHeader('Content-Disposition', 'attachment; filename="audit-trail-export.csv"'); + response.send(csv); + } + + @Post('audit-trail') + createAuditTrailEntry( + @Req() request: Request & { user: AuthenticatedUser }, + @Body() dto: CreateAuditLogDto, + ) { + const forwarded = request.headers['x-forwarded-for']; + const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip; + return this.logsService.createAuditLog(dto, request.user, ipAddress); + } +} diff --git a/backend/src/logs/logs.module.ts b/backend/src/logs/logs.module.ts new file mode 100644 index 0000000..e3cfbc4 --- /dev/null +++ b/backend/src/logs/logs.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { WebhooksModule } from '../webhooks/webhooks.module'; +import { LogsController } from './logs.controller'; +import { LogsService } from './logs.service'; + +@Module({ + imports: [AuthModule, WebhooksModule], + controllers: [LogsController], + providers: [LogsService], +}) +export class LogsModule {} diff --git a/backend/src/logs/logs.service.ts b/backend/src/logs/logs.service.ts new file mode 100644 index 0000000..6b8ecf3 --- /dev/null +++ b/backend/src/logs/logs.service.ts @@ -0,0 +1,181 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { JobsService } from '../jobs/jobs.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; +import { CreateAuditLogDto } from './dto/create-audit-log.dto'; + +@Injectable() +export class LogsService { + constructor( + private readonly prisma: PrismaService, + private readonly jobsService: JobsService, + private readonly webhooksService: WebhooksService, + ) {} + + getWebhookLogs(limit?: number, provider?: string, status?: string) { + return this.webhooksService.findAll(limit, provider, status); + } + + getJobLogs(limit?: number, queueName?: string, status?: string) { + return this.jobsService.findAll(limit, queueName, status); + } + + getAuditTrail( + page = 1, + limit = 100, + range?: string, + actorName?: string, + actionType?: string, + moduleName?: string, + search?: string, + ) { + const where: Prisma.AuditLogWhereInput = { + ...(actorName ? { actorName } : {}), + ...(actionType ? { actionType } : {}), + ...(moduleName ? { module: moduleName } : {}), + }; + + const trimmedSearch = search?.trim(); + if (trimmedSearch) { + where.OR = [ + { actorName: { contains: trimmedSearch, mode: 'insensitive' } }, + { actorEmail: { contains: trimmedSearch, mode: 'insensitive' } }, + { actionType: { contains: trimmedSearch, mode: 'insensitive' } }, + { module: { contains: trimmedSearch, mode: 'insensitive' } }, + { details: { contains: trimmedSearch, mode: 'insensitive' } }, + { ipAddress: { contains: trimmedSearch, mode: 'insensitive' } }, + ]; + } + + const createdAt = this.resolveCreatedAtRange(range); + if (createdAt) { + where.createdAt = createdAt; + } + + const take = Math.min(Math.max(limit, 1), 500); + const skip = Math.max(page - 1, 0) * take; + + return this.prisma.$transaction(async (tx) => { + const [items, total] = await Promise.all([ + tx.auditLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take, + skip, + }), + tx.auditLog.count({ where }), + ]); + + return { + items, + total, + page, + pageSize: take, + totalPages: Math.max(1, Math.ceil(total / take)), + }; + }); + } + + async exportAuditTrailCsv( + range?: string, + actorName?: string, + actionType?: string, + moduleName?: string, + search?: string, + ) { + const result = await this.getAuditTrail(1, 5000, range, actorName, actionType, moduleName, search); + const header = ['Timestamp', 'Admin User', 'Admin Email', 'Action Type', 'Module', 'IP Address', 'Severity', 'Details']; + const rows = result.items.map((item) => + [ + item.createdAt.toISOString(), + item.actorName, + item.actorEmail || '', + item.actionType, + item.module, + item.ipAddress || '', + item.severity, + item.details, + ] + .map((cell) => `"${String(cell).replaceAll('"', '""')}"`) + .join(','), + ); + + return [header.join(','), ...rows].join('\n'); + } + + async createAuditLog( + dto: CreateAuditLogDto, + user: AuthenticatedUser, + ipAddress?: string, + ) { + const actor = await this.prisma.user.findUnique({ + where: { id: user.sub }, + select: { id: true, name: true, email: true }, + }); + + return this.prisma.auditLog.create({ + data: { + actorUserId: actor?.id, + actorName: actor?.name || user.email, + actorEmail: actor?.email || user.email, + actionType: dto.actionType, + module: dto.module, + ipAddress: ipAddress || null, + severity: dto.severity || 'default', + details: dto.details, + }, + }); + } + + createSystemAuditLog(input: { + actorUserId?: string | null; + actorName: string; + actorEmail?: string | null; + actionType: string; + module: string; + ipAddress?: string | null; + severity?: 'default' | 'alert'; + details: string; + metadataJson?: Prisma.InputJsonValue; + }) { + return this.prisma.auditLog.create({ + data: { + actorUserId: input.actorUserId || null, + actorName: input.actorName, + actorEmail: input.actorEmail || null, + actionType: input.actionType, + module: input.module, + ipAddress: input.ipAddress || null, + severity: input.severity || 'default', + details: input.details, + metadataJson: input.metadataJson, + }, + }); + } + + private resolveCreatedAtRange(range?: string): Prisma.DateTimeFilter | undefined { + if (!range || range === 'all') { + return undefined; + } + + const now = Date.now(); + const offset = + range === '24h' + ? 24 * 60 * 60 * 1000 + : range === '7d' + ? 7 * 24 * 60 * 60 * 1000 + : range === '30d' + ? 30 * 24 * 60 * 60 * 1000 + : null; + + if (!offset) { + return undefined; + } + + return { + gte: new Date(now - offset), + }; + } +} diff --git a/backend/src/mailer/mailer.module.ts b/backend/src/mailer/mailer.module.ts new file mode 100644 index 0000000..eba8e9c --- /dev/null +++ b/backend/src/mailer/mailer.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { MailerService } from './mailer.service'; + +@Module({ + providers: [MailerService], + exports: [MailerService], +}) +export class MailerModule {} diff --git a/backend/src/mailer/mailer.service.ts b/backend/src/mailer/mailer.service.ts new file mode 100644 index 0000000..93af978 --- /dev/null +++ b/backend/src/mailer/mailer.service.ts @@ -0,0 +1,172 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; +import { getAppConfig } from '../config/env'; + +@Injectable() +export class MailerService { + private readonly logger = new Logger(MailerService.name); + private readonly config = getAppConfig(); + + private createTransport() { + if (!this.config.mailHost || !this.config.mailUser || !this.config.mailPassword) { + return null; + } + + return nodemailer.createTransport({ + host: this.config.mailHost, + port: this.config.mailPort, + secure: this.config.mailSecure, + auth: { + user: this.config.mailUser, + pass: this.config.mailPassword, + }, + }); + } + + async sendInvitationEmail(input: { + to: string; + name: string; + roleName: string; + invitationUrl: string; + invitedBy: string; + }) { + const transporter = this.createTransport(); + if (!transporter) { + this.logger.warn(`SMTP config missing, invitation email skipped for ${input.to}`); + return { delivered: false }; + } + + await transporter.sendMail({ + from: this.config.mailFrom, + to: input.to, + subject: 'Set up your BizOne account', + text: [ + `Hello ${input.name},`, + '', + `${input.invitedBy} invited you to BizOne as ${input.roleName}.`, + 'Open the link below to verify your email and create your password:', + input.invitationUrl, + '', + 'This invitation link will expire in 24 hours.', + ].join('\n'), + html: ` +
+

BizOne Account Invitation

+

Hello ${escapeHtml(input.name)},

+

${escapeHtml(input.invitedBy)} invited you to BizOne as ${escapeHtml(input.roleName)}.

+

Click the button below to verify your email and create your password.

+

+ + Set Password + +

+

If the button does not work, open this link manually:

+

${input.invitationUrl}

+

This invitation link will expire in 24 hours.

+
+ `, + }); + + return { delivered: true }; + } + + async sendPasswordResetEmail(input: { + to: string; + name: string; + resetUrl: string; + }) { + const transporter = this.createTransport(); + if (!transporter) { + this.logger.warn(`SMTP config missing, password reset email skipped for ${input.to}`); + return { delivered: false }; + } + + await transporter.sendMail({ + from: this.config.mailFrom, + to: input.to, + subject: 'Reset your BizOne password', + text: [ + `Hello ${input.name},`, + '', + 'We received a request to reset your BizOne password.', + 'Open the link below to choose a new password:', + input.resetUrl, + '', + 'This reset link will expire in 1 hour.', + 'If you did not request this, you can ignore this email.', + ].join('\n'), + html: ` +
+

BizOne Password Reset

+

Hello ${escapeHtml(input.name)},

+

We received a request to reset your BizOne password.

+

Click the button below to choose a new password.

+

+ + Reset Password + +

+

If the button does not work, open this link manually:

+

${input.resetUrl}

+

This reset link will expire in 1 hour. If you did not request this, you can ignore this email.

+
+ `, + }); + + return { delivered: true }; + } + + async sendSecurityNotificationEmail(input: { + to: string; + name: string; + subject: string; + heading: string; + intro: string; + bullets: string[]; + note?: string; + }) { + const transporter = this.createTransport(); + if (!transporter) { + this.logger.warn(`SMTP config missing, security notification email skipped for ${input.to}`); + return { delivered: false }; + } + + const bulletText = input.bullets.map((item) => `- ${item}`).join('\n'); + const bulletHtml = input.bullets.map((item) => `
  • ${escapeHtml(item)}
  • `).join(''); + + await transporter.sendMail({ + from: this.config.mailFrom, + to: input.to, + subject: input.subject, + text: [ + `Hello ${input.name},`, + '', + input.intro, + '', + bulletText, + '', + input.note || 'If this was not expected, review your account security immediately.', + ].join('\n'), + html: ` +
    +

    ${escapeHtml(input.heading)}

    +

    Hello ${escapeHtml(input.name)},

    +

    ${escapeHtml(input.intro)}

    +
      ${bulletHtml}
    +

    ${escapeHtml(input.note || 'If this was not expected, review your account security immediately.')}

    +
    + `, + }); + + return { delivered: true }; + } +} + +function escapeHtml(value: string) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..be3ba24 --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,36 @@ +import 'reflect-metadata'; +import { json } from 'express'; +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { PrismaExceptionFilter } from './common/prisma-exception.filter'; +import { getAppConfig } from './config/env'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const config = getAppConfig(); + const app = await NestFactory.create(AppModule); + app.use( + json({ + verify: (request: { rawBody?: Buffer } & object, _response, buffer) => { + request.rawBody = Buffer.from(buffer); + }, + }), + ); + app.setGlobalPrefix('api'); + app.getHttpAdapter().getInstance().disable('x-powered-by'); + app.enableCors({ + origin: config.corsOrigins, + credentials: true, + }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + app.useGlobalFilters(new PrismaExceptionFilter()); + app.enableShutdownHooks(); + await app.listen(config.port); +} +bootstrap(); diff --git a/backend/src/prisma/prisma.module.ts b/backend/src/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/backend/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/backend/src/prisma/prisma.service.ts b/backend/src/prisma/prisma.service.ts new file mode 100644 index 0000000..0e00df3 --- /dev/null +++ b/backend/src/prisma/prisma.service.ts @@ -0,0 +1,25 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { getAppConfig } from '../config/env'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor() { + const config = getAppConfig(); + super({ + datasources: { + db: { + url: config.databaseUrl, + }, + }, + }); + } + + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/backend/src/roles/dto/create-role.dto.ts b/backend/src/roles/dto/create-role.dto.ts new file mode 100644 index 0000000..a326df8 --- /dev/null +++ b/backend/src/roles/dto/create-role.dto.ts @@ -0,0 +1,33 @@ +import { IsArray, IsIn, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; + +export class CreateRoleDto { + @IsString() + @IsNotEmpty() + name!: string; + + @IsString() + @IsOptional() + summary?: string; + + @IsString() + @IsOptional() + badge?: string; + + @IsString() + @IsOptional() + @IsIn(['primary', 'secondary', 'tertiary']) + tone?: 'primary' | 'secondary' | 'tertiary'; + + @IsString() + @IsOptional() + icon?: string; + + @IsArray() + permissions!: Array<{ + id: string; + label: string; + icon: string; + description: string; + values: Record; + }>; +} diff --git a/backend/src/roles/dto/update-role.dto.ts b/backend/src/roles/dto/update-role.dto.ts new file mode 100644 index 0000000..30279f4 --- /dev/null +++ b/backend/src/roles/dto/update-role.dto.ts @@ -0,0 +1,35 @@ +import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class UpdateRoleDto { + @IsString() + @IsOptional() + @IsNotEmpty() + name?: string; + + @IsString() + @IsOptional() + summary?: string; + + @IsString() + @IsOptional() + badge?: string; + + @IsString() + @IsOptional() + @IsIn(['primary', 'secondary', 'tertiary']) + tone?: 'primary' | 'secondary' | 'tertiary'; + + @IsString() + @IsOptional() + icon?: string; + + @IsArray() + @IsOptional() + permissions?: Array<{ + id: string; + label: string; + icon: string; + description: string; + values: Record; + }>; +} diff --git a/backend/src/roles/roles.controller.ts b/backend/src/roles/roles.controller.ts new file mode 100644 index 0000000..a498a42 --- /dev/null +++ b/backend/src/roles/roles.controller.ts @@ -0,0 +1,40 @@ +import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { AuthGuard } from '../common/auth.guard'; +import { RequirePermission } from '../common/permission.decorator'; +import { PermissionGuard } from '../common/permission.guard'; +import { CreateRoleDto } from './dto/create-role.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; +import { RolesService } from './roles.service'; + +@UseGuards(AuthGuard, PermissionGuard) +@Controller('roles') +export class RolesController { + constructor(private readonly rolesService: RolesService) {} + + @Get() + @RequirePermission('roles', 'view') + findAll() { + return this.rolesService.findAll(); + } + + @Post() + @RequirePermission('roles', 'manage') + create( + @Req() request: Request & { user: AuthenticatedUser }, + @Body() dto: CreateRoleDto, + ) { + return this.rolesService.create(dto, request.user, request.ip); + } + + @Patch(':id') + @RequirePermission('roles', 'manage') + update( + @Req() request: Request & { user: AuthenticatedUser }, + @Param('id') id: string, + @Body() dto: UpdateRoleDto, + ) { + return this.rolesService.update(id, dto, request.user, request.ip); + } +} diff --git a/backend/src/roles/roles.module.ts b/backend/src/roles/roles.module.ts new file mode 100644 index 0000000..85f0b83 --- /dev/null +++ b/backend/src/roles/roles.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { RolesController } from './roles.controller'; +import { RolesService } from './roles.service'; + +@Module({ + imports: [AuthModule], + controllers: [RolesController], + providers: [RolesService], + exports: [RolesService], +}) +export class RolesModule {} diff --git a/backend/src/roles/roles.service.ts b/backend/src/roles/roles.service.ts new file mode 100644 index 0000000..2ef1c58 --- /dev/null +++ b/backend/src/roles/roles.service.ts @@ -0,0 +1,242 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { normalizeText } from '../common/normalize'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateRoleDto } from './dto/create-role.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; + +type PermissionRow = { + id: string; + label: string; + icon: string; + description: string; + values: Record; +}; + +const defaultRoles: Array<{ + key: string; + name: string; + summary: string; + badge: string; + tone: 'primary' | 'secondary' | 'tertiary'; + icon: string; + permissions: PermissionRow[]; +}> = [ + { + key: 'admin', + name: 'Admin', + summary: 'Full access to all modules, security controls, system configuration, and account-wide actions.', + badge: 'Active', + tone: 'primary', + icon: 'shield_person', + permissions: [ + { id: 'campaigns', label: 'Manage Campaigns', icon: 'campaign', description: 'Broadcasts and outbound campaign controls.', values: { view: true, edit: true, delete: true, manage: true } }, + { id: 'analytics', label: 'View Analytics', icon: 'monitoring', description: 'KPI, trends, and performance dashboards.', values: { view: true, edit: null, delete: null, manage: true } }, + { id: 'settings', label: 'Edit Settings', icon: 'settings', description: 'Providers, secrets, and environment-facing settings.', values: { view: true, edit: true, delete: true, manage: true } }, + { id: 'billing', label: 'Billing & Invoices', icon: 'payments', description: 'Plan usage, invoices, and billing visibility.', values: { view: true, edit: null, delete: null, manage: true } }, + ], + }, + { + key: 'editor', + name: 'Editor', + summary: 'Can create templates and manage campaigns, but cannot change security policy or critical settings.', + badge: 'Standard', + tone: 'secondary', + icon: 'edit_document', + permissions: [ + { id: 'campaigns', label: 'Manage Campaigns', icon: 'campaign', description: 'Broadcasts and outbound campaign controls.', values: { view: true, edit: true, delete: false, manage: false } }, + { id: 'analytics', label: 'View Analytics', icon: 'monitoring', description: 'KPI, trends, and performance dashboards.', values: { view: true, edit: null, delete: null, manage: false } }, + { id: 'settings', label: 'Edit Settings', icon: 'settings', description: 'Providers, secrets, and environment-facing settings.', values: { view: false, edit: false, delete: false, manage: false } }, + { id: 'billing', label: 'Billing & Invoices', icon: 'payments', description: 'Plan usage, invoices, and billing visibility.', values: { view: false, edit: null, delete: null, manage: false } }, + ], + }, + { + key: 'agent', + name: 'Agent', + summary: 'Focused access for daily operations: conversations, contact handling, and high-signal analytics visibility.', + badge: 'Standard', + tone: 'tertiary', + icon: 'support_agent', + permissions: [ + { id: 'campaigns', label: 'Manage Campaigns', icon: 'campaign', description: 'Broadcasts and outbound campaign controls.', values: { view: true, edit: false, delete: false, manage: false } }, + { id: 'analytics', label: 'View Analytics', icon: 'monitoring', description: 'KPI, trends, and performance dashboards.', values: { view: true, edit: null, delete: null, manage: false } }, + { id: 'settings', label: 'Edit Settings', icon: 'settings', description: 'Providers, secrets, and environment-facing settings.', values: { view: false, edit: false, delete: false, manage: false } }, + { id: 'billing', label: 'Billing & Invoices', icon: 'payments', description: 'Plan usage, invoices, and billing visibility.', values: { view: false, edit: null, delete: null, manage: false } }, + ], + }, +]; + +@Injectable() +export class RolesService { + constructor(private readonly prisma: PrismaService) {} + + async findAll() { + await this.ensureDefaultRoles(); + + const roles = await this.prisma.role.findMany({ + include: { + _count: { + select: { users: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }); + + return roles.map((role) => ({ + id: role.id, + key: role.key, + name: role.name, + summary: role.summary, + badge: role.badge, + tone: role.tone, + icon: role.icon, + usersAssigned: role._count.users, + permissions: role.permissionsJson, + })); + } + + async create(dto: CreateRoleDto, user: AuthenticatedUser, ipAddress?: string) { + await this.ensureDefaultRoles(); + const actor = await this.findActor(user.sub, user.email); + const name = normalizeText(dto.name)!; + const key = this.slugify(name); + + const role = await this.prisma.role.create({ + data: { + key, + name, + summary: normalizeText(dto.summary) || 'Custom role for a focused operational access policy.', + badge: normalizeText(dto.badge) || 'Custom', + tone: dto.tone || 'secondary', + icon: normalizeText(dto.icon) || 'verified_user', + permissionsJson: dto.permissions as Prisma.InputJsonValue, + }, + include: { + _count: { select: { users: true } }, + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + actionType: 'Role Created', + module: 'Access Control', + ipAddress: ipAddress || null, + severity: 'default', + details: `Created role ${role.name}.`, + }, + }); + + return { + id: role.id, + key: role.key, + name: role.name, + summary: role.summary, + badge: role.badge, + tone: role.tone, + icon: role.icon, + usersAssigned: role._count.users, + permissions: role.permissionsJson, + }; + } + + async update(id: string, dto: UpdateRoleDto, user: AuthenticatedUser, ipAddress?: string) { + const actor = await this.findActor(user.sub, user.email); + + const role = await this.prisma.role.update({ + where: { id }, + data: { + ...(dto.name !== undefined ? { name: normalizeText(dto.name)! } : {}), + ...(dto.summary !== undefined ? { summary: normalizeText(dto.summary) || '' } : {}), + ...(dto.badge !== undefined ? { badge: normalizeText(dto.badge) || 'Custom' } : {}), + ...(dto.tone !== undefined ? { tone: dto.tone } : {}), + ...(dto.icon !== undefined ? { icon: normalizeText(dto.icon) || 'verified_user' } : {}), + ...(dto.permissions !== undefined + ? { permissionsJson: dto.permissions as Prisma.InputJsonValue } + : {}), + }, + include: { + _count: { select: { users: true } }, + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + actionType: 'User Role Updated', + module: 'Access Control', + ipAddress: ipAddress || null, + severity: 'default', + details: `Updated role ${role.name}.`, + }, + }); + + return { + id: role.id, + key: role.key, + name: role.name, + summary: role.summary, + badge: role.badge, + tone: role.tone, + icon: role.icon, + usersAssigned: role._count.users, + permissions: role.permissionsJson, + }; + } + + private async ensureDefaultRoles() { + const count = await this.prisma.role.count(); + if (count > 0) { + return; + } + + for (const role of defaultRoles) { + await this.prisma.role.create({ + data: { + key: role.key, + name: role.name, + summary: role.summary, + badge: role.badge, + tone: role.tone, + icon: role.icon, + permissionsJson: role.permissions as Prisma.InputJsonValue, + }, + }); + } + + const adminRole = await this.prisma.role.findUnique({ where: { key: 'admin' } }); + if (adminRole) { + await this.prisma.user.updateMany({ + where: { roleId: null }, + data: { roleId: adminRole.id }, + }); + } + } + + private async findActor(userId: string, email: string) { + const actor = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, name: true, email: true }, + }); + + return { + id: actor?.id || userId, + name: actor?.name || email, + email: actor?.email || email, + }; + } + + private slugify(value: string) { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') || `role-${Date.now()}` + ); + } +} diff --git a/backend/src/templates/dto/create-template.dto.ts b/backend/src/templates/dto/create-template.dto.ts new file mode 100644 index 0000000..9eac616 --- /dev/null +++ b/backend/src/templates/dto/create-template.dto.ts @@ -0,0 +1,47 @@ +import { Transform } from 'class-transformer'; +import { IsArray, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateTemplateDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MaxLength(120) + name!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + category!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + status!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + language!: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + headerText?: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + bodyText!: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + footerText?: string; + + @IsOptional() + @IsArray() + buttons?: Array<{ type: string; label: string }>; +} diff --git a/backend/src/templates/dto/update-template.dto.ts b/backend/src/templates/dto/update-template.dto.ts new file mode 100644 index 0000000..75091c1 --- /dev/null +++ b/backend/src/templates/dto/update-template.dto.ts @@ -0,0 +1,52 @@ +import { Transform } from 'class-transformer'; +import { IsArray, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpdateTemplateDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsOptional() + @IsString() + @MaxLength(120) + name?: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsOptional() + @IsString() + category?: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsOptional() + @IsString() + status?: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsOptional() + @IsString() + language?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + headerText?: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsOptional() + @IsString() + bodyText?: string; + + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed || undefined; + }) + @IsOptional() + @IsString() + footerText?: string; + + @IsOptional() + @IsArray() + buttons?: Array<{ type: string; label: string }>; +} diff --git a/backend/src/templates/templates.controller.ts b/backend/src/templates/templates.controller.ts new file mode 100644 index 0000000..7a47dc7 --- /dev/null +++ b/backend/src/templates/templates.controller.ts @@ -0,0 +1,51 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { AuthGuard } from '../common/auth.guard'; +import { PermissionGuard } from '../common/permission.guard'; +import { RequirePermission } from '../common/permission.decorator'; +import { CreateTemplateDto } from './dto/create-template.dto'; +import { UpdateTemplateDto } from './dto/update-template.dto'; +import { TemplatesService } from './templates.service'; + +@UseGuards(AuthGuard, PermissionGuard) +@Controller('templates') +export class TemplatesController { + constructor(private readonly templatesService: TemplatesService) {} + + @Get() + @RequirePermission('templates', 'view') + findAll( + @Query('search') search?: string, + @Query('category') category?: string, + @Query('status') status?: string, + @Query('language') language?: string, + ) { + return this.templatesService.findAll({ search, category, status, language }); + } + + @Get(':id') + @RequirePermission('templates', 'view') + findOne(@Param('id') id: string) { + return this.templatesService.findOne(id); + } + + @Post() + @RequirePermission('templates', 'edit') + create( + @Req() request: Request & { user: AuthenticatedUser }, + @Body() dto: CreateTemplateDto, + ) { + return this.templatesService.create(dto, request.user, request.ip); + } + + @Patch(':id') + @RequirePermission('templates', 'edit') + update( + @Req() request: Request & { user: AuthenticatedUser }, + @Param('id') id: string, + @Body() dto: UpdateTemplateDto, + ) { + return this.templatesService.update(id, dto, request.user, request.ip); + } +} diff --git a/backend/src/templates/templates.module.ts b/backend/src/templates/templates.module.ts new file mode 100644 index 0000000..7e402c1 --- /dev/null +++ b/backend/src/templates/templates.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { TemplatesController } from './templates.controller'; +import { TemplatesService } from './templates.service'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [TemplatesController], + providers: [TemplatesService], + exports: [TemplatesService], +}) +export class TemplatesModule {} diff --git a/backend/src/templates/templates.service.ts b/backend/src/templates/templates.service.ts new file mode 100644 index 0000000..0d28673 --- /dev/null +++ b/backend/src/templates/templates.service.ts @@ -0,0 +1,341 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { normalizeText } from '../common/normalize'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateTemplateDto } from './dto/create-template.dto'; +import { UpdateTemplateDto } from './dto/update-template.dto'; + +type SeedTemplate = { + name: string; + category: string; + status: string; + language: string; + headerText?: string; + bodyText: string; + footerText?: string; + buttons?: Array<{ type: string; label: string }>; +}; + +const seededTemplates: SeedTemplate[] = [ + { + name: 'order_confirmation_v2', + category: 'Utility', + status: 'Approved', + language: 'en_US', + bodyText: "Hi {{1}}, thank you for your order #{{2}}! We've received your payment and will notify you when it ships...", + footerText: 'Reply STOP to opt out.', + buttons: [{ type: 'quick_reply', label: 'Track Order' }], + }, + { + name: 'holiday_sale_promo', + category: 'Marketing', + status: 'Pending', + language: 'en_US', + headerText: 'Holiday Sale', + bodyText: 'Exclusive Holiday Sale! Get 30% OFF on all items using code FESTIVE30. Shop now at {{1}}...', + footerText: 'Valid through Sunday only.', + buttons: [{ type: 'quick_reply', label: 'Shop Now' }], + }, + { + name: 'account_recovery_otp', + category: 'Authentication', + status: 'Rejected', + language: 'en_US', + bodyText: 'Your recovery code is {{1}}. Do not share this with anyone. This code expires in 5 minutes.', + footerText: 'Security automation', + }, + { + name: 'shipping_update_express', + category: 'Utility', + status: 'Approved', + language: 'en_US', + bodyText: 'Good news! Your package is out for delivery and should arrive before 7 PM today.', + buttons: [{ type: 'quick_reply', label: 'View Delivery' }], + }, +]; + +@Injectable() +export class TemplatesService { + constructor(private readonly prisma: PrismaService) {} + + async findAll(params?: { search?: string; category?: string; status?: string; language?: string }) { + await this.ensureSeedData(); + + const search = params?.search?.trim(); + const category = params?.category?.trim(); + const status = params?.status?.trim(); + const language = params?.language?.trim(); + + const where: Prisma.MessageTemplateWhereInput = { + ...(category ? { category: { equals: category, mode: 'insensitive' } } : {}), + ...(status ? { status: { equals: status, mode: 'insensitive' } } : {}), + ...(language ? { language: { equals: language, mode: 'insensitive' } } : {}), + ...(search + ? { + OR: [ + { name: { contains: search, mode: 'insensitive' } }, + { category: { contains: search, mode: 'insensitive' } }, + { bodyText: { contains: search, mode: 'insensitive' } }, + ], + } + : {}), + }; + + const templates = await this.prisma.messageTemplate.findMany({ + where, + orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }], + }); + + return { + total: templates.length, + items: templates.map((template) => this.serializeListItem(template)), + }; + } + + async findOne(id: string) { + await this.ensureSeedData(); + const template = await this.prisma.messageTemplate.findUnique({ where: { id } }); + if (!template) { + throw new NotFoundException('Template not found'); + } + + return this.serializeDetail(template); + } + + async create(dto: CreateTemplateDto, user: AuthenticatedUser, ipAddress?: string) { + await this.ensureSeedData(); + await this.assertUniqueName(dto.name); + const actor = await this.findActor(user.sub, user.email); + + const template = await this.prisma.messageTemplate.create({ + data: { + name: normalizeText(dto.name)!, + category: normalizeText(dto.category) || 'Utility', + status: normalizeText(dto.status) || 'Draft', + language: normalizeText(dto.language) || 'en_US', + headerText: normalizeText(dto.headerText), + bodyText: normalizeText(dto.bodyText) || '', + footerText: normalizeText(dto.footerText), + buttonsJson: this.normalizeButtons(dto.buttons), + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + actionType: 'Template Created', + module: 'Templates', + ipAddress: ipAddress || null, + severity: 'default', + details: `Created template ${template.name}.`, + }, + }); + + return this.serializeDetail(template); + } + + async update(id: string, dto: UpdateTemplateDto, user: AuthenticatedUser, ipAddress?: string) { + await this.ensureSeedData(); + const current = await this.prisma.messageTemplate.findUnique({ where: { id } }); + if (!current) { + throw new NotFoundException('Template not found'); + } + + if (dto.name && dto.name.trim().toLowerCase() !== current.name.toLowerCase()) { + await this.assertUniqueName(dto.name); + } + + const actor = await this.findActor(user.sub, user.email); + const template = await this.prisma.messageTemplate.update({ + where: { id }, + data: { + ...(dto.name !== undefined ? { name: normalizeText(dto.name)! } : {}), + ...(dto.category !== undefined ? { category: normalizeText(dto.category) || current.category } : {}), + ...(dto.status !== undefined ? { status: normalizeText(dto.status) || current.status } : {}), + ...(dto.language !== undefined ? { language: normalizeText(dto.language) || current.language } : {}), + ...(dto.headerText !== undefined ? { headerText: normalizeText(dto.headerText) } : {}), + ...(dto.bodyText !== undefined ? { bodyText: normalizeText(dto.bodyText) || current.bodyText } : {}), + ...(dto.footerText !== undefined ? { footerText: normalizeText(dto.footerText) } : {}), + ...(dto.buttons !== undefined ? { buttonsJson: this.normalizeButtons(dto.buttons) } : {}), + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + actionType: 'Template Updated', + module: 'Templates', + ipAddress: ipAddress || null, + severity: 'default', + details: `Updated template ${template.name}.`, + }, + }); + + return this.serializeDetail(template); + } + + async assertTemplateExistsByName(name: string | null | undefined) { + if (!name?.trim()) { + return; + } + + const template = await this.prisma.messageTemplate.findFirst({ + where: { + name: { equals: name.trim(), mode: 'insensitive' }, + }, + select: { id: true }, + }); + + if (!template) { + throw new NotFoundException(`Template "${name}" not found`); + } + } + + private serializeListItem(template: { + id: string; + name: string; + category: string; + status: string; + language: string; + headerText: string | null; + bodyText: string; + footerText: string | null; + buttonsJson: Prisma.JsonValue | null; + updatedAt: Date; + }) { + const buttons = this.readButtons(template.buttonsJson); + const preview = template.bodyText.length > 120 ? `${template.bodyText.slice(0, 117)}...` : template.bodyText; + + return { + id: template.id, + name: template.name, + category: template.category, + status: template.status, + language: template.language, + updatedAt: template.updatedAt.toISOString(), + updatedLabel: this.relativeTimeLabel(template.updatedAt), + preview, + compact: buttons.length > 0, + buttons, + }; + } + + private serializeDetail(template: { + id: string; + name: string; + category: string; + status: string; + language: string; + headerText: string | null; + bodyText: string; + footerText: string | null; + buttonsJson: Prisma.JsonValue | null; + createdAt: Date; + updatedAt: Date; + }) { + return { + id: template.id, + name: template.name, + category: template.category, + status: template.status, + language: template.language, + headerText: template.headerText, + bodyText: template.bodyText, + footerText: template.footerText, + buttons: this.readButtons(template.buttonsJson), + createdAt: template.createdAt.toISOString(), + updatedAt: template.updatedAt.toISOString(), + }; + } + + private normalizeButtons(buttons?: Array<{ type: string; label: string }>) { + if (!Array.isArray(buttons)) { + return Prisma.JsonNull; + } + + return buttons + .map((button) => ({ + type: normalizeText(button.type) || 'quick_reply', + label: normalizeText(button.label) || '', + })) + .filter((button) => button.label.length > 0) as Prisma.InputJsonValue; + } + + private readButtons(value: Prisma.JsonValue | null) { + if (!Array.isArray(value)) { + return [] as Array<{ type: string; label: string }>; + } + + return value + .map((item) => (item && typeof item === 'object' ? (item as Prisma.JsonObject) : null)) + .filter((item): item is Prisma.JsonObject => Boolean(item)) + .map((item) => ({ + type: typeof item.type === 'string' ? item.type : 'quick_reply', + label: typeof item.label === 'string' ? item.label : '', + })) + .filter((button) => button.label.length > 0); + } + + private async assertUniqueName(name: string) { + const existing = await this.prisma.messageTemplate.findFirst({ + where: { name: { equals: name.trim(), mode: 'insensitive' } }, + select: { id: true }, + }); + + if (existing) { + throw new ConflictException('Template name already exists'); + } + } + + private async ensureSeedData() { + const count = await this.prisma.messageTemplate.count(); + if (count > 0) { + return; + } + + for (const template of seededTemplates) { + await this.prisma.messageTemplate.create({ + data: { + name: template.name, + category: template.category, + status: template.status, + language: template.language, + headerText: template.headerText || null, + bodyText: template.bodyText, + footerText: template.footerText || null, + buttonsJson: template.buttons ? (template.buttons as Prisma.InputJsonValue) : Prisma.JsonNull, + }, + }); + } + } + + private async findActor(userId: string, email: string) { + const actor = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, name: true, email: true }, + }); + + return { + id: actor?.id || userId, + name: actor?.name || email, + email: actor?.email || email, + }; + } + + private relativeTimeLabel(date: Date) { + const diffMs = Date.now() - date.getTime(); + const diffMinutes = Math.max(1, Math.floor(diffMs / 60000)); + + if (diffMinutes < 60) return `Updated ${diffMinutes}m ago`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `Updated ${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) return `Updated ${diffDays}d ago`; + return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date); + } +} diff --git a/backend/src/types/bcryptjs.d.ts b/backend/src/types/bcryptjs.d.ts new file mode 100644 index 0000000..aee2b5a --- /dev/null +++ b/backend/src/types/bcryptjs.d.ts @@ -0,0 +1 @@ +declare module 'bcryptjs'; diff --git a/backend/src/users/dto/complete-invitation.dto.ts b/backend/src/users/dto/complete-invitation.dto.ts new file mode 100644 index 0000000..4f7d91b --- /dev/null +++ b/backend/src/users/dto/complete-invitation.dto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; + +export class CompleteInvitationDto { + @IsString() + @IsNotEmpty() + @MinLength(8) + password!: string; +} diff --git a/backend/src/users/dto/create-user.dto.ts b/backend/src/users/dto/create-user.dto.ts new file mode 100644 index 0000000..6adcc69 --- /dev/null +++ b/backend/src/users/dto/create-user.dto.ts @@ -0,0 +1,14 @@ +import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateUserDto { + @IsString() + @IsNotEmpty() + name!: string; + + @IsEmail() + email!: string; + + @IsString() + @IsOptional() + roleId?: string; +} diff --git a/backend/src/users/dto/update-user.dto.ts b/backend/src/users/dto/update-user.dto.ts new file mode 100644 index 0000000..6976c38 --- /dev/null +++ b/backend/src/users/dto/update-user.dto.ts @@ -0,0 +1,20 @@ +import { IsEmail, IsIn, IsOptional, IsString } from 'class-validator'; + +export class UpdateUserDto { + @IsString() + @IsOptional() + name?: string; + + @IsEmail() + @IsOptional() + email?: string; + + @IsString() + @IsOptional() + roleId?: string; + + @IsString() + @IsOptional() + @IsIn(['invited', 'active', 'inactive', 'suspended']) + status?: 'invited' | 'active' | 'inactive' | 'suspended'; +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts new file mode 100644 index 0000000..7be049a --- /dev/null +++ b/backend/src/users/users.controller.ts @@ -0,0 +1,84 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, Req, Res, UseGuards } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { AuthGuard } from '../common/auth.guard'; +import { RequirePermission } from '../common/permission.decorator'; +import { PermissionGuard } from '../common/permission.guard'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { UsersService } from './users.service'; +import { CompleteInvitationDto } from './dto/complete-invitation.dto'; + +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @UseGuards(AuthGuard, PermissionGuard) + @Get() + @RequirePermission('users', 'view') + findAll( + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('search') search?: string, + @Query('roleId') roleId?: string, + @Query('status') status?: string, + ) { + return this.usersService.findAll({ + page: page ? Number(page) : 1, + limit: limit ? Number(limit) : 10, + search, + roleId, + status, + }); + } + + @UseGuards(AuthGuard, PermissionGuard) + @Get('export') + @RequirePermission('users', 'view') + async exportCsv( + @Res() response: Response, + @Query('search') search?: string, + @Query('roleId') roleId?: string, + @Query('status') status?: string, + ) { + const csv = await this.usersService.exportCsv({ search, roleId, status }); + response.setHeader('Content-Type', 'text/csv; charset=utf-8'); + response.setHeader('Content-Disposition', 'attachment; filename="users-export.csv"'); + response.send(csv); + } + + @UseGuards(AuthGuard, PermissionGuard) + @Post('invite') + @RequirePermission('users', 'manage') + invite( + @Req() request: Request & { user: AuthenticatedUser }, + @Body() dto: CreateUserDto, + ) { + return this.usersService.invite(dto, request.user, request.ip); + } + + @UseGuards(AuthGuard, PermissionGuard) + @Patch(':id') + @RequirePermission('users', 'manage') + update( + @Req() request: Request & { user: AuthenticatedUser }, + @Param('id') id: string, + @Body() dto: UpdateUserDto, + ) { + return this.usersService.update(id, dto, request.user, request.ip); + } + + @Get('invitations/:token') + getInvitation(@Param('token') token: string) { + return this.usersService.getInvitation(token); + } + + @Post('invitations/:token/complete') + completeInvitation( + @Req() request: Request, + @Param('token') token: string, + @Body() dto: CompleteInvitationDto, + ) { + return this.usersService.completeInvitation(token, dto, request.ip); + } +} diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts new file mode 100644 index 0000000..7235fe3 --- /dev/null +++ b/backend/src/users/users.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { MailerModule } from '../mailer/mailer.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + imports: [PrismaModule, MailerModule, AuthModule], + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts new file mode 100644 index 0000000..ccfae07 --- /dev/null +++ b/backend/src/users/users.service.ts @@ -0,0 +1,380 @@ +import { + BadRequestException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { createHash, randomBytes } from 'node:crypto'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { normalizeEmail, normalizeText } from '../common/normalize'; +import { hasMinimumPasswordLength, hashPassword } from '../common/password'; +import { getAppConfig } from '../config/env'; +import { MailerService } from '../mailer/mailer.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { CompleteInvitationDto } from './dto/complete-invitation.dto'; + +const config = getAppConfig(); + +@Injectable() +export class UsersService { + constructor( + private readonly prisma: PrismaService, + private readonly mailer: MailerService, + ) {} + + async findAll(params?: { + page?: number; + limit?: number; + search?: string; + roleId?: string; + status?: string; + }) { + const page = Math.max(1, params?.page || 1); + const limit = Math.min(50, Math.max(1, params?.limit || 10)); + const where = this.buildWhere(params); + + const [users, total] = await Promise.all([ + this.prisma.user.findMany({ + where, + include: { + role: { + select: { id: true, name: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.user.count({ where }), + ]); + + return { + items: users.map((user) => ({ + id: user.id, + name: user.name, + email: user.email, + status: user.status, + roleId: user.roleId, + roleName: user.role?.name || 'Unassigned', + createdAt: user.createdAt, + updatedAt: user.updatedAt, + lastLoginAt: user.lastLoginAt, + emailVerifiedAt: user.emailVerifiedAt, + })), + total, + page, + pageSize: limit, + totalPages: Math.max(1, Math.ceil(total / limit)), + }; + } + + async exportCsv(params?: { + search?: string; + roleId?: string; + status?: string; + }) { + const users = await this.prisma.user.findMany({ + where: this.buildWhere(params), + include: { + role: { + select: { id: true, name: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + const header = [ + 'Name', + 'Email', + 'Role', + 'Status', + 'Created At', + 'Updated At', + 'Last Login At', + 'Email Verified At', + ]; + const rows = users.map((user) => + [ + user.name, + user.email, + user.role?.name || 'Unassigned', + user.status, + user.createdAt.toISOString(), + user.updatedAt.toISOString(), + user.lastLoginAt?.toISOString() || '', + user.emailVerifiedAt?.toISOString() || '', + ] + .map((cell) => `"${String(cell).replaceAll('"', '""')}"`) + .join(','), + ); + + return [header.join(','), ...rows].join('\n'); + } + + async invite(dto: CreateUserDto, actorUser: AuthenticatedUser, ipAddress?: string) { + const actor = await this.findActor(actorUser.sub, actorUser.email); + const email = normalizeEmail(dto.email); + const name = normalizeText(dto.name); + if (!name) { + throw new BadRequestException('Name is required'); + } + + const role = dto.roleId + ? await this.prisma.role.findUnique({ where: { id: dto.roleId } }) + : await this.prisma.role.findUnique({ where: { key: 'agent' } }); + + if (dto.roleId && !role) { + throw new NotFoundException('Role not found'); + } + + const existingUser = await this.prisma.user.findUnique({ + where: { email }, + select: { id: true, status: true }, + }); + + if (existingUser?.status === 'active') { + throw new BadRequestException('User already exists and is active'); + } + + const token = randomBytes(24).toString('hex'); + const tokenHash = this.hashToken(token); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + const user = await this.prisma.user.upsert({ + where: { email }, + update: { + name, + roleId: role?.id || null, + status: 'invited', + inviteTokenHash: tokenHash, + inviteTokenExpiresAt: expiresAt, + passwordHash: null, + emailVerifiedAt: null, + }, + create: { + name, + email, + roleId: role?.id || null, + status: 'invited', + inviteTokenHash: tokenHash, + inviteTokenExpiresAt: expiresAt, + }, + include: { + role: { + select: { name: true }, + }, + }, + }); + + const invitationUrl = `${config.frontendOrigin}/invite/${token}`; + let mailResult: { delivered: boolean } = { delivered: false }; + try { + mailResult = await this.mailer.sendInvitationEmail({ + to: user.email, + name: user.name, + roleName: user.role?.name || 'User', + invitationUrl, + invitedBy: actor.name, + }); + } catch { + mailResult = { delivered: false }; + } + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + actionType: 'User Invited', + module: 'User Management', + ipAddress: ipAddress || null, + severity: 'default', + details: `Invited user ${user.email} as ${user.role?.name || 'Unassigned'}.`, + }, + }); + + return { + id: user.id, + name: user.name, + email: user.email, + status: user.status, + roleId: user.roleId, + roleName: user.role?.name || 'Unassigned', + invitationUrl, + emailSent: mailResult.delivered, + }; + } + + async update(id: string, dto: UpdateUserDto, actorUser: AuthenticatedUser, ipAddress?: string) { + const actor = await this.findActor(actorUser.sub, actorUser.email); + if (dto.roleId) { + const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } }); + if (!role) { + throw new NotFoundException('Role not found'); + } + } + + const user = await this.prisma.user.update({ + where: { id }, + data: { + ...(dto.name !== undefined ? { name: normalizeText(dto.name) || '' } : {}), + ...(dto.email !== undefined ? { email: normalizeEmail(dto.email) } : {}), + ...(dto.roleId !== undefined ? { roleId: dto.roleId || null } : {}), + ...(dto.status !== undefined ? { status: dto.status } : {}), + }, + include: { + role: { + select: { name: true }, + }, + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + actionType: 'User Updated', + module: 'User Management', + ipAddress: ipAddress || null, + severity: 'default', + details: `Updated user ${user.email}.`, + }, + }); + + return { + id: user.id, + name: user.name, + email: user.email, + status: user.status, + roleId: user.roleId, + roleName: user.role?.name || 'Unassigned', + lastLoginAt: user.lastLoginAt, + emailVerifiedAt: user.emailVerifiedAt, + }; + } + + async getInvitation(token: string) { + const invite = await this.findInvitationByToken(token); + return { + email: invite.email, + name: invite.name, + roleName: invite.role?.name || 'User', + expiresAt: invite.inviteTokenExpiresAt, + }; + } + + async completeInvitation(token: string, dto: CompleteInvitationDto, ipAddress?: string) { + if (!hasMinimumPasswordLength(dto.password)) { + throw new BadRequestException('Password must be at least 8 characters'); + } + + const invite = await this.findInvitationByToken(token); + const passwordHash = await hashPassword(dto.password); + + const user = await this.prisma.user.update({ + where: { id: invite.id }, + data: { + passwordHash, + status: 'active', + emailVerifiedAt: new Date(), + inviteTokenHash: null, + inviteTokenExpiresAt: null, + }, + include: { + role: { + select: { name: true }, + }, + }, + }); + + await this.prisma.auditLog.create({ + data: { + actorUserId: user.id, + actorName: user.name, + actorEmail: user.email, + actionType: 'User Activated', + module: 'User Management', + ipAddress: ipAddress || null, + severity: 'default', + details: `Completed password setup for ${user.email}.`, + }, + }); + + return { + id: user.id, + email: user.email, + status: user.status, + }; + } + + private async findInvitationByToken(token: string) { + const invite = await this.prisma.user.findFirst({ + where: { + inviteTokenHash: this.hashToken(token), + }, + include: { + role: { + select: { name: true }, + }, + }, + }); + + if (!invite || !invite.inviteTokenExpiresAt || invite.inviteTokenExpiresAt.getTime() < Date.now()) { + throw new UnauthorizedException('Invitation token is invalid or expired'); + } + + return invite; + } + + private async findActor(userId: string, email: string) { + const actor = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, name: true, email: true }, + }); + + return { + id: actor?.id || userId, + name: actor?.name || email, + email: actor?.email || email, + }; + } + + private hashToken(token: string) { + return createHash('sha256').update(token).digest('hex'); + } + + private buildWhere(params?: { + search?: string; + roleId?: string; + status?: string; + }): Prisma.UserWhereInput { + const search = params?.search?.trim(); + const roleId = params?.roleId?.trim(); + const status = params?.status?.trim(); + + const and: Prisma.UserWhereInput[] = []; + + if (search) { + and.push({ + OR: [ + { name: { contains: search, mode: 'insensitive' } }, + { email: { contains: search, mode: 'insensitive' } }, + ], + }); + } + + if (roleId) { + and.push({ roleId }); + } + + if (status && ['invited', 'active', 'inactive', 'suspended'].includes(status)) { + and.push({ status: status as 'invited' | 'active' | 'inactive' | 'suspended' }); + } + + return and.length > 0 ? { AND: and } : {}; + } +} diff --git a/backend/src/webhooks/webhook-worker.service.ts b/backend/src/webhooks/webhook-worker.service.ts new file mode 100644 index 0000000..12d094d --- /dev/null +++ b/backend/src/webhooks/webhook-worker.service.ts @@ -0,0 +1,42 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import type { Job as BullJob, Worker } from 'bullmq'; +import { JobsService } from '../jobs/jobs.service'; +import { RedisQueueService } from '../jobs/redis-queue.service'; +import { WebhooksService } from './webhooks.service'; + +@Injectable() +export class WebhookWorkerService implements OnModuleInit, OnModuleDestroy { + private worker: Worker | null = null; + + constructor( + private readonly jobsService: JobsService, + private readonly redisQueueService: RedisQueueService, + private readonly webhooksService: WebhooksService, + ) {} + + onModuleInit() { + this.worker = this.redisQueueService.createWorker( + 'webhooks', + async (job: BullJob<{ dbJobId?: string }>) => { + const dbJobId = job.data?.dbJobId; + if (!dbJobId) { + throw new Error('Redis webhook job missing dbJobId'); + } + + const claimed = await this.jobsService.markProcessing(dbJobId); + if (!claimed) { + return; + } + + await this.webhooksService.processJob(dbJobId); + }, + ); + } + + async onModuleDestroy() { + if (this.worker) { + await this.worker.close(); + this.worker = null; + } + } +} diff --git a/backend/src/webhooks/webhooks.controller.ts b/backend/src/webhooks/webhooks.controller.ts new file mode 100644 index 0000000..634660a --- /dev/null +++ b/backend/src/webhooks/webhooks.controller.ts @@ -0,0 +1,76 @@ +import { + Body, + Controller, + Get, + Headers, + HttpCode, + Param, + Post as HttpPost, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import type { Request } from 'express'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { WebhooksService } from './webhooks.service'; +import { AuthGuard } from '../common/auth.guard'; + +@Controller('webhooks') +export class WebhooksController { + constructor(private readonly webhooksService: WebhooksService) {} + + @Get('whatsapp') + verify( + @Query('hub.mode') mode?: string, + @Query('hub.verify_token') token?: string, + @Query('hub.challenge') challenge?: string, + ) { + return this.webhooksService.verifyChallenge(mode, token, challenge); + } + + @HttpCode(200) + @Post('whatsapp') + receiveDefault( + @Body() body: unknown, + @Req() request: Request & { rawBody?: Buffer }, + @Headers() headers: Record, + ) { + return this.webhooksService.receive('default', body, headers, request.rawBody); + } + + @HttpCode(200) + @Post('whatsapp/:provider') + receiveByProvider( + @Param('provider') provider: string, + @Body() body: unknown, + @Req() request: Request & { rawBody?: Buffer }, + @Headers() headers: Record, + ) { + return this.webhooksService.receive(provider, body, headers, request.rawBody); + } + + @UseGuards(AuthGuard) + @Get('logs') + logs( + @Query('limit') limit?: string, + @Query('provider') provider?: string, + @Query('status') status?: string, + ) { + const parsedLimit = limit ? Number(limit) : 50; + return this.webhooksService.findAll( + Number.isFinite(parsedLimit) ? parsedLimit : 50, + provider, + status, + ); + } + + @UseGuards(AuthGuard) + @HttpPost('logs/:eventId/retry') + retry( + @Req() request: Request & { user: AuthenticatedUser }, + @Param('eventId') eventId: string, + ) { + return this.webhooksService.retryEvent(eventId, request.user, request.ip); + } +} diff --git a/backend/src/webhooks/webhooks.module.ts b/backend/src/webhooks/webhooks.module.ts new file mode 100644 index 0000000..ec2e296 --- /dev/null +++ b/backend/src/webhooks/webhooks.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { ConversationsModule } from '../conversations/conversations.module'; +import { JobsModule } from '../jobs/jobs.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { WebhooksController } from './webhooks.controller'; +import { WebhookWorkerService } from './webhook-worker.service'; +import { WebhooksService } from './webhooks.service'; + +@Module({ + imports: [PrismaModule, AuthModule, JobsModule, ConversationsModule], + controllers: [WebhooksController], + providers: [WebhooksService, WebhookWorkerService], + exports: [WebhooksService], +}) +export class WebhooksModule {} diff --git a/backend/src/webhooks/webhooks.service.ts b/backend/src/webhooks/webhooks.service.ts new file mode 100644 index 0000000..3d5208c --- /dev/null +++ b/backend/src/webhooks/webhooks.service.ts @@ -0,0 +1,392 @@ +import { Prisma } from '@prisma/client'; +import { + BadRequestException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { getAppConfig } from '../config/env'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { ConversationsService } from '../conversations/conversations.service'; +import { JobsService } from '../jobs/jobs.service'; +import { defaultWhatsappSubscriptions } from '../integrations/whatsapp-subscriptions'; +import { normalizePhoneNumber } from '../common/normalize'; +import { PrismaService } from '../prisma/prisma.service'; +import { + normalizeWebhookPayload, + verifyMetaSignature, +} from './webhooks.utils'; +import type { + NormalizedWebhookEvent, + WebhookHeaders, + WebhookReceiveSummary, +} from './webhooks.types'; + +@Injectable() +export class WebhooksService { + constructor( + private readonly prisma: PrismaService, + private readonly jobsService: JobsService, + private readonly conversationsService: ConversationsService, + ) {} + + async verifyChallenge(mode?: string, token?: string, challenge?: string) { + const config = await this.getEffectiveWhatsappConfig(); + + if (mode !== 'subscribe') { + throw new BadRequestException('Unsupported webhook verification mode'); + } + + if ((token || '') !== config.webhookVerifyToken) { + throw new UnauthorizedException('Invalid webhook verify token'); + } + + return challenge || ''; + } + + async receive( + provider: string, + payload: unknown, + headers: WebhookHeaders, + rawBody?: Buffer, + ): Promise { + const verification = await this.verifyWebhookRequest(provider, rawBody, headers); + const normalizedEvents = normalizeWebhookPayload(provider, payload); + + let queued = 0; + let duplicates = 0; + let ignored = 0; + const queuedEventIds: string[] = []; + const config = await this.getEffectiveWhatsappConfig(); + + for (const event of normalizedEvents) { + const outcome = await this.persistNormalizedEvent( + event, + verification.verified, + config.subscriptions.includes(event.eventType), + ); + if (outcome === 'queued') { + queued += 1; + queuedEventIds.push(event.eventId); + } else if (outcome === 'duplicate') { + duplicates += 1; + } else { + ignored += 1; + } + } + + return { + provider: provider.toLowerCase(), + verified: verification.verified, + verification: verification.reason, + received: normalizedEvents.length, + queued, + duplicates, + ignored, + eventIds: normalizedEvents.map((event) => event.eventId), + }; + } + + findAll(limit = 50, provider?: string, status?: string) { + return this.prisma.webhookEvent.findMany({ + where: { + provider: provider?.trim() || undefined, + processingStatus: status?.trim() || undefined, + }, + orderBy: { createdAt: 'desc' }, + take: Math.min(Math.max(limit, 1), 100), + }); + } + + async retryEvent(eventId: string, user?: AuthenticatedUser, ipAddress?: string) { + const event = await this.prisma.webhookEvent.findUnique({ + where: { eventId }, + }); + + if (!event) { + throw new NotFoundException('Webhook event not found'); + } + + await this.prisma.$transaction(async (tx) => { + await tx.webhookEvent.update({ + where: { eventId }, + data: { + processingStatus: 'queued', + processingNotes: 'Manually requeued from retry endpoint', + }, + }); + }); + + await this.jobsService.enqueue({ + queueName: 'webhooks', + jobType: 'webhook.process', + payload: { eventId }, + maxAttempts: 3, + }); + + if (user) { + const actor = await this.prisma.user.findUnique({ + where: { id: user.sub }, + select: { id: true, name: true, email: true }, + }); + await this.prisma.auditLog.create({ + data: { + actorUserId: actor?.id || user.sub, + actorName: actor?.name || user.email, + actorEmail: actor?.email || user.email, + actionType: 'Webhook Retry Queued', + module: 'Webhooks', + ipAddress: ipAddress || null, + severity: 'default', + details: `Manually requeued webhook event ${eventId}.`, + }, + }); + } + + return { + eventId, + status: 'queued', + }; + } + + async processJob(jobId: string) { + const job = await this.jobsService.findById(jobId); + if (!job) { + return; + } + + try { + const payload = job.payloadJson as { eventId?: string }; + const eventId = payload?.eventId; + + if (!eventId) { + throw new Error('Webhook job payload is missing eventId'); + } + + await this.processSingleEvent(eventId); + await this.jobsService.complete(jobId); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown processing error'; + + if (job.attempts < job.maxAttempts) { + await this.jobsService.retry(jobId, message, 2000 * job.attempts); + } else { + await this.jobsService.fail(jobId, message); + } + } + } + + private async persistNormalizedEvent( + event: NormalizedWebhookEvent, + verified: boolean, + isSubscribed: boolean, + ) { + try { + await this.prisma.$transaction(async (tx) => { + await tx.webhookEvent.create({ + data: { + provider: event.provider, + eventId: event.eventId, + eventType: event.eventType, + senderPhone: event.senderPhone, + recipientPhone: event.recipientPhone, + externalMessageId: event.externalMessageId, + eventTimestamp: event.eventTimestamp, + payloadJson: event.payload as Prisma.InputJsonValue, + verified, + processingStatus: isSubscribed ? 'queued' : 'ignored', + processingNotes: isSubscribed + ? 'Queued for webhook worker' + : 'Ignored because event subscription is disabled', + }, + }); + }); + + if (!isSubscribed) { + return 'ignored' as const; + } + + await this.jobsService.enqueue({ + queueName: 'webhooks', + jobType: 'webhook.process', + payload: { eventId: event.eventId }, + maxAttempts: 3, + }); + + return 'queued' as const; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + return 'duplicate' as const; + } + + throw error; + } + } + + private async verifyWebhookRequest( + provider: string, + rawBody: Buffer | undefined, + headers: WebhookHeaders, + ) { + const config = await this.getEffectiveWhatsappConfig(); + const normalizedProvider = provider.toLowerCase(); + const metaSignature = this.readHeader(headers['x-hub-signature-256']); + const genericSecret = this.readHeader(headers['x-webhook-secret']); + + if (normalizedProvider === 'meta' && config.appSecret) { + if (!rawBody || !metaSignature) { + throw new UnauthorizedException('Missing meta webhook signature'); + } + + verifyMetaSignature(rawBody, metaSignature, config.appSecret); + return { verified: true, reason: 'meta-signature' }; + } + + if (genericSecret) { + if (genericSecret !== config.sharedSecret) { + throw new UnauthorizedException('Invalid webhook shared secret'); + } + + return { verified: true, reason: 'shared-secret' }; + } + + if (config.allowUnsigned) { + return { verified: false, reason: 'unsigned-development-request' }; + } + + throw new UnauthorizedException('Webhook request could not be verified'); + } + + private async getEffectiveWhatsappConfig() { + const env = getAppConfig(); + const stored = await this.prisma.integrationConfig.findUnique({ + where: { configKey: 'whatsapp' }, + }); + const storedJson = (stored?.configJson as Record | null) ?? {}; + + return { + webhookVerifyToken: + typeof storedJson.webhookVerifyToken === 'string' + ? storedJson.webhookVerifyToken + : env.webhookVerifyToken, + sharedSecret: + typeof storedJson.sharedSecret === 'string' + ? storedJson.sharedSecret + : env.webhookSharedSecret, + appSecret: + typeof storedJson.appSecret === 'string' + ? storedJson.appSecret + : env.metaWebhookAppSecret, + allowUnsigned: env.webhookAllowUnsigned, + subscriptions: + Array.isArray(storedJson.subscriptions) && storedJson.subscriptions.length > 0 + ? storedJson.subscriptions.filter((item): item is string => typeof item === 'string') + : defaultWhatsappSubscriptions, + }; + } + + private readHeader(value: string | string[] | undefined) { + if (Array.isArray(value)) { + return value[0]; + } + + return value; + } + + private async processSingleEvent(eventId: string) { + const event = await this.prisma.webhookEvent.findUnique({ + where: { eventId }, + }); + + if (!event) { + throw new Error(`Webhook event ${eventId} not found`); + } + + if (event.processingStatus === 'processed') { + return; + } + + if (event.eventType === 'message.inbound' && event.senderPhone) { + const normalizedPhone = normalizePhoneNumber(event.senderPhone); + const contact = await this.prisma.contact.upsert({ + where: { phoneNumber: normalizedPhone }, + update: {}, + create: { + name: normalizedPhone, + phoneNumber: normalizedPhone, + notes: `Created from inbound webhook event ${event.eventId}`, + }, + }); + + await this.conversationsService.syncInboundFromWebhookEvent({ + webhookEventId: event.eventId, + contactId: contact.id, + externalMessageId: event.externalMessageId, + body: this.extractMessageBody(event.payloadJson), + occurredAt: event.eventTimestamp, + }); + } + + if ( + (event.eventType === 'message.sent' || event.eventType === 'message.delivered' || event.eventType === 'message.read' || event.eventType === 'message.failed') + && event.externalMessageId + ) { + await this.prisma.conversationMessage.updateMany({ + where: { + externalMessageId: event.externalMessageId, + }, + data: { + status: + event.eventType === 'message.read' + ? 'read' + : event.eventType === 'message.delivered' + ? 'delivered' + : event.eventType === 'message.failed' + ? 'failed' + : 'sent', + ...(event.eventType === 'message.read' ? { readAt: event.eventTimestamp } : {}), + }, + }); + } + + await this.prisma.webhookEvent.update({ + where: { eventId }, + data: { + processingStatus: 'processed', + processingNotes: + event.eventType === 'message.inbound' && event.senderPhone + ? 'Processed and synced inbound sender into contacts' + : 'Processed by webhook worker', + }, + }); + } + + private extractMessageBody(payload: unknown) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return 'Inbound message received.'; + } + + const record = payload as Record; + const textRecord = + record.text && typeof record.text === 'object' && !Array.isArray(record.text) + ? (record.text as Record) + : null; + const interactiveRecord = + record.interactive && typeof record.interactive === 'object' && !Array.isArray(record.interactive) + ? (record.interactive as Record) + : null; + + return ( + [ + typeof textRecord?.body === 'string' ? textRecord.body : null, + typeof record.body === 'string' ? record.body : null, + typeof record.caption === 'string' ? record.caption : null, + typeof interactiveRecord?.title === 'string' ? interactiveRecord.title : null, + typeof record.type === 'string' ? `[${record.type}]` : null, + ].find((value) => typeof value === 'string' && value.trim()) || 'Inbound message received.' + ); + } +} diff --git a/backend/src/webhooks/webhooks.types.ts b/backend/src/webhooks/webhooks.types.ts new file mode 100644 index 0000000..c0616f7 --- /dev/null +++ b/backend/src/webhooks/webhooks.types.ts @@ -0,0 +1,28 @@ +export type WebhookHeaders = Record; + +export type WebhookVerificationResult = { + verified: boolean; + reason: string; +}; + +export type NormalizedWebhookEvent = { + provider: string; + eventType: string; + eventId: string; + senderPhone?: string; + recipientPhone?: string; + externalMessageId?: string; + eventTimestamp: Date; + payload: Record; +}; + +export type WebhookReceiveSummary = { + provider: string; + verified: boolean; + verification: string; + received: number; + queued: number; + duplicates: number; + ignored: number; + eventIds: string[]; +}; diff --git a/backend/src/webhooks/webhooks.utils.ts b/backend/src/webhooks/webhooks.utils.ts new file mode 100644 index 0000000..cd8f17d --- /dev/null +++ b/backend/src/webhooks/webhooks.utils.ts @@ -0,0 +1,216 @@ +import { createHmac, createHash, timingSafeEqual } from 'node:crypto'; +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import type { + NormalizedWebhookEvent, +} from './webhooks.types'; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + return value as Record; +} + +function asArray(value: unknown) { + return Array.isArray(value) ? value : []; +} + +function readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function readTimestamp(value: unknown) { + if (typeof value === 'number' || (typeof value === 'string' && value.trim())) { + const raw = String(value).trim(); + const milliseconds = /^\d+$/.test(raw) ? Number(raw) * 1000 : Date.parse(raw); + if (!Number.isNaN(milliseconds)) { + return new Date(milliseconds); + } + } + + return new Date(); +} + +function buildEventId(provider: string, payload: Record, seed?: string) { + const hash = createHash('sha256') + .update(provider) + .update(':') + .update(seed || JSON.stringify(payload)) + .digest('hex'); + + return `evt_${hash.slice(0, 32)}`; +} + +function buildMetaEvents(payload: Record, provider: string) { + const normalized: NormalizedWebhookEvent[] = []; + const entries = asArray(payload.entry); + + for (const entry of entries) { + const entryRecord = asRecord(entry); + const changes = asArray(entryRecord?.changes); + + for (const change of changes) { + const changeRecord = asRecord(change); + const field = readString(changeRecord?.field); + const value = asRecord(changeRecord?.value); + + if (!value) { + continue; + } + + const metadata = asRecord(value.metadata); + const recipientPhone = readString(metadata?.display_phone_number) || readString(metadata?.phone_number_id); + + for (const status of asArray(value.statuses)) { + const statusRecord = asRecord(status); + if (!statusRecord) { + continue; + } + + const rawStatus = readString(statusRecord.status) || 'sent'; + const statusMap: Record = { + sent: 'message.sent', + delivered: 'message.delivered', + read: 'message.read', + failed: 'message.failed', + }; + + normalized.push({ + provider, + eventType: statusMap[rawStatus] || 'message.sent', + eventId: + readString(statusRecord.id) || + readString(statusRecord.meta_msg_id) || + buildEventId(provider, statusRecord, rawStatus), + senderPhone: readString(statusRecord.recipient_id), + recipientPhone, + externalMessageId: readString(statusRecord.id), + eventTimestamp: readTimestamp(statusRecord.timestamp), + payload: statusRecord, + }); + } + + for (const message of asArray(value.messages)) { + const messageRecord = asRecord(message); + if (!messageRecord) { + continue; + } + + const contacts = asArray(value.contacts); + const firstContact = asRecord(contacts[0]); + const senderPhone = + readString(messageRecord.from) || + readString(firstContact?.wa_id) || + readString(firstContact?.phone); + + normalized.push({ + provider, + eventType: field === 'messages' ? 'message.inbound' : 'account.updated', + eventId: + readString(messageRecord.id) || + buildEventId(provider, messageRecord, readString(messageRecord.from)), + senderPhone, + recipientPhone, + externalMessageId: readString(messageRecord.id), + eventTimestamp: readTimestamp(messageRecord.timestamp), + payload: messageRecord, + }); + } + + if (field === 'message_template_status_update') { + normalized.push({ + provider, + eventType: 'template.updated', + eventId: buildEventId(provider, value, 'template'), + recipientPhone, + eventTimestamp: new Date(), + payload: value, + }); + } + } + } + + return normalized; +} + +function buildGenericEvents(payload: Record, provider: string) { + const eventType = + readString(payload.event_type) || + readString(payload.eventType) || + readString(payload.type) || + 'account.updated'; + const senderPhone = + readString(payload.sender_phone) || + readString(payload.senderPhone) || + readString(payload.from); + const recipientPhone = + readString(payload.recipient_phone) || + readString(payload.recipientPhone) || + readString(payload.to); + const externalMessageId = + readString(payload.external_message_id) || + readString(payload.externalMessageId) || + readString(payload.message_id) || + readString(payload.messageId); + const seed = + readString(payload.event_id) || + readString(payload.id) || + externalMessageId || + JSON.stringify(payload); + + return [ + { + provider, + eventType, + eventId: + readString(payload.event_id) || + readString(payload.eventId) || + readString(payload.id) || + buildEventId(provider, payload, seed), + senderPhone, + recipientPhone, + externalMessageId, + eventTimestamp: readTimestamp(payload.timestamp || payload.created_at || payload.createdAt), + payload, + }, + ]; +} + +export function verifyMetaSignature(rawBody: Buffer, signatureHeader: string, appSecret: string) { + const [scheme, receivedSignature] = signatureHeader.split('='); + if (scheme !== 'sha256' || !receivedSignature) { + throw new UnauthorizedException('Invalid meta webhook signature format'); + } + + const expectedSignature = createHmac('sha256', appSecret).update(rawBody).digest('hex'); + const receivedBuffer = Buffer.from(receivedSignature, 'hex'); + const expectedBuffer = Buffer.from(expectedSignature, 'hex'); + + if ( + receivedBuffer.length !== expectedBuffer.length || + !timingSafeEqual(receivedBuffer, expectedBuffer) + ) { + throw new UnauthorizedException('Invalid meta webhook signature'); + } +} + +export function normalizeWebhookPayload(provider: string, payload: unknown) { + const payloadRecord = asRecord(payload); + if (!payloadRecord) { + throw new BadRequestException('Webhook payload must be a JSON object'); + } + + const normalizedProvider = provider.toLowerCase(); + if ( + normalizedProvider === 'meta' && + readString(payloadRecord.object) === 'whatsapp_business_account' + ) { + const metaEvents = buildMetaEvents(payloadRecord, normalizedProvider); + if (metaEvents.length > 0) { + return metaEvents; + } + } + + return buildGenericEvents(payloadRecord, normalizedProvider); +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..ccf2bb2 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": false, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strict": true, + "moduleResolution": "node", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/deploy/debian12/README.md b/deploy/debian12/README.md new file mode 100644 index 0000000..0ccdb53 --- /dev/null +++ b/deploy/debian12/README.md @@ -0,0 +1,314 @@ +# Deploy Debian 12 for `portal.bizone.id` + +Panduan ini menyiapkan `bizone-web` di server Debian 12 kosong dengan topologi berikut: + +- `nginx` sebagai reverse proxy publik +- `frontend` Next.js pada `127.0.0.1:3000` +- `backend` NestJS pada `127.0.0.1:3001` +- `PostgreSQL` dan `Redis` via Docker Compose +- TLS dari Let's Encrypt + +## URL Production Final + +- Aplikasi: `https://portal.bizone.id` +- Backend API public base URL: `https://portal.bizone.id/api` +- Health check backend: `https://portal.bizone.id/api/health` +- Webhook verify URL Meta: `https://portal.bizone.id/api/webhooks/whatsapp` +- Webhook event URL Meta: `https://portal.bizone.id/api/webhooks/whatsapp` +- Alternate provider-specific webhook URL: `https://portal.bizone.id/api/webhooks/whatsapp/meta` +- Webhook logs UI: `https://portal.bizone.id/dashboard/webhooks/logs` + +Untuk integrasi Meta, gunakan URL default berikut: + +- Callback URL: `https://portal.bizone.id/api/webhooks/whatsapp` +- Verify token: nilai `WEBHOOK_VERIFY_TOKEN` yang sama persis dengan env production + +Catatan penting: + +- Route backend memakai global prefix `/api`, jadi endpoint controller `GET /webhooks/whatsapp` menjadi `GET /api/webhooks/whatsapp`. +- Jika Anda ingin verifikasi tanda tangan resmi dari Meta, isi `META_WEBHOOK_APP_SECRET`. +- Bila `META_WEBHOOK_APP_SECRET` terisi, request ke `POST /api/webhooks/whatsapp/meta` menuntut header `x-hub-signature-256`. +- Endpoint `POST /api/webhooks/whatsapp` tetap bisa dipakai untuk Meta bila Anda memilih verify token + shared secret non-Meta untuk test lain, tetapi untuk produksi Meta lebih aman menargetkan URL default callback dan menyimpan `META_WEBHOOK_APP_SECRET`. + +## 1. DNS + +Buat `A record`: + +- `portal.bizone.id` -> IP publik server Debian 12 + +Pastikan propagasi selesai sebelum minta sertifikat TLS. + +## 2. Install paket dasar + +```bash +sudo apt update +sudo apt install -y nginx certbot python3-certbot-nginx curl git build-essential +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +sudo apt install -y nodejs +curl -fsSL https://get.docker.com | sudo sh +sudo usermod -aG docker $USER +``` + +Logout lalu login ulang setelah menambahkan grup `docker`. + +Verifikasi: + +```bash +node -v +npm -v +docker --version +docker compose version +``` + +## 3. Siapkan user dan direktori aplikasi + +```bash +sudo useradd -m -s /bin/bash bizone +sudo mkdir -p /srv +sudo chown -R $USER:$USER /srv +cd /srv +git clone https://git.iptek.co/wirabasalamah/BizOne-portal.git bizone-web +cd /srv/bizone-web +``` + +Jika repo dikirim manual: + +```bash +sudo mkdir -p /srv/bizone-web +sudo chown -R $USER:$USER /srv/bizone-web +``` + +Lalu salin source code ke `/srv/bizone-web`. + +## 4. Install dependency dan build + +Root dependency Prisma: + +```bash +cd /srv/bizone-web +npm install +``` + +Backend: + +```bash +cd /srv/bizone-web/backend +npm install +npm run build +``` + +Frontend: + +```bash +cd /srv/bizone-web/frontend +npm install +npm run build +``` + +## 5. Siapkan `.env` production + +Salin template: + +```bash +cp /srv/bizone-web/deploy/debian12/app.env.example /srv/bizone-web/.env +``` + +Lalu ubah semua placeholder, terutama: + +- `DATABASE_URL` +- `JWT_SECRET` +- `JWT_REFRESH_SECRET` +- `WEBHOOK_VERIFY_TOKEN` +- `WEBHOOK_SHARED_SECRET` +- `META_WEBHOOK_APP_SECRET` +- `MAIL_*` + +Nilai yang wajib dipakai untuk domain ini: + +```dotenv +NODE_ENV=production +FRONTEND_ORIGIN=https://portal.bizone.id +PUBLIC_API_URL=https://portal.bizone.id +NEXT_PUBLIC_API_URL=https://portal.bizone.id/api +PORT=3001 +WEBHOOK_ALLOW_UNSIGNED=false +``` + +Generate secret aman: + +```bash +openssl rand -hex 32 +openssl rand -hex 32 +openssl rand -hex 32 +openssl rand -hex 32 +``` + +Gunakan hasil berbeda untuk: + +- `JWT_SECRET` +- `JWT_REFRESH_SECRET` +- `WEBHOOK_VERIFY_TOKEN` +- `WEBHOOK_SHARED_SECRET` + +## 6. Jalankan PostgreSQL dan Redis + +```bash +cd /srv/bizone-web/deploy/debian12 +docker compose -f docker-compose.infra.yml up -d +docker compose -f docker-compose.infra.yml ps +``` + +## 7. Generate Prisma client dan migrasi database + +```bash +cd /srv/bizone-web/backend +npm run db:generate +npm run db:migrate:deploy +``` + +Jika database masih kosong total, Anda bisa seed admin: + +```bash +npm run seed:admin +``` + +Seed default saat ini: + +- Email: `admin@example.com` +- Password: `ChangeMe123!` + +Masuk lalu segera ganti password. + +## 8. Pasang service `systemd` + +Salin file service: + +```bash +sudo cp /srv/bizone-web/deploy/debian12/bizone-backend.service /etc/systemd/system/ +sudo cp /srv/bizone-web/deploy/debian12/bizone-frontend.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable bizone-backend bizone-frontend +sudo systemctl start bizone-backend bizone-frontend +``` + +Cek status: + +```bash +sudo systemctl status bizone-backend +sudo systemctl status bizone-frontend +``` + +Lihat log: + +```bash +sudo journalctl -u bizone-backend -f +sudo journalctl -u bizone-frontend -f +``` + +## 9. Pasang `nginx` + +Salin config: + +```bash +sudo cp /srv/bizone-web/deploy/debian12/nginx.portal.bizone.id.conf /etc/nginx/sites-available/portal.bizone.id +sudo ln -s /etc/nginx/sites-available/portal.bizone.id /etc/nginx/sites-enabled/portal.bizone.id +sudo nginx -t +sudo systemctl reload nginx +``` + +Uji HTTP lokal: + +```bash +curl -I http://portal.bizone.id +curl http://portal.bizone.id/api/health +``` + +## 10. Aktifkan HTTPS + +```bash +sudo certbot --nginx -d portal.bizone.id +``` + +Setelah cert aktif, uji: + +```bash +curl -I https://portal.bizone.id +curl https://portal.bizone.id/api/health +``` + +Respons health ideal: + +```json +{"status":"ok","service":"wa-dashboard-backend","database":"ok","timestamp":"..."} +``` + +## 11. Data Meta yang harus Anda masukkan + +Di Meta App Dashboard / WhatsApp configuration: + +- Callback URL: `https://portal.bizone.id/api/webhooks/whatsapp` +- Verify token: isi dengan nilai `WEBHOOK_VERIFY_TOKEN` + +Jika Anda menyimpan `META_WEBHOOK_APP_SECRET`, backend akan memverifikasi signature event masuk dari header `x-hub-signature-256`. + +Subscription event yang relevan untuk aplikasi ini: + +- `messages` +- `message_deliveries` +- `message_read` +- `message_sent` +- `message_failed` +- `template_category_update` +- `account_update` + +Mapping internal saat ini: + +- `messages` -> `message.inbound` +- `message_deliveries` -> `message.delivered` +- `message_read` -> `message.read` +- `message_sent` -> `message.sent` +- `message_failed` -> `message.failed` +- `template_category_update` -> `template.updated` +- `account_update` -> `account.updated` + +## 12. Urutan test live yang saya sarankan + +1. Pastikan `https://portal.bizone.id/api/health` mengembalikan `200`. +2. Coba buka `https://portal.bizone.id`. +3. Lakukan webhook verification dari Meta dengan callback URL production. +4. Kirim test webhook dari Meta. +5. Login ke dashboard dan buka `Dashboard > Webhooks Logs`. +6. Kirim pesan WhatsApp sungguhan ke nomor bisnis yang terhubung. +7. Pastikan inbound message masuk ke inbox conversation. +8. Balas dari dashboard bila access token dan `phoneNumberId` sudah terisi. +9. Cek status `sent`, `delivered`, `read`, atau `failed` kembali masuk lewat webhook. + +## 13. Command update deploy berikutnya + +Setelah ada perubahan code: + +```bash +cd /srv/bizone-web +git pull +npm install +cd backend && npm install && npm run build && npm run db:generate && npm run db:migrate:deploy +cd ../frontend && npm install && npm run build +sudo systemctl restart bizone-backend bizone-frontend +``` + +Jika server belum punya identity Git dan Anda ingin commit langsung dari server: + +```bash +git config user.name "Wira Irawan" +git config user.email "wira.irawan@gmail.com" +``` + +## 14. Smoke check minimal + +```bash +curl https://portal.bizone.id/api/health +curl -I https://portal.bizone.id +sudo systemctl is-active bizone-backend +sudo systemctl is-active bizone-frontend +docker compose -f /srv/bizone-web/deploy/debian12/docker-compose.infra.yml ps +``` diff --git a/deploy/debian12/app.env.example b/deploy/debian12/app.env.example new file mode 100644 index 0000000..11ade2f --- /dev/null +++ b/deploy/debian12/app.env.example @@ -0,0 +1,33 @@ +NODE_ENV=production + +DATABASE_URL=postgresql://bizone:change-this-postgres-password@127.0.0.1:5432/wa_dashboard +REDIS_URL=redis://127.0.0.1:6379 + +PORT=3001 +FRONTEND_ORIGIN=https://portal.bizone.id +PUBLIC_API_URL=https://portal.bizone.id +NEXT_PUBLIC_API_URL=https://portal.bizone.id/api + +JWT_SECRET=UsmlPBa61fKDgTjUR+9sS9f5SKw3OF7X0CjGWoHibg2eF7gQO6sS57pc2Hj8XIv4 +JWT_EXPIRES_IN=1d +JWT_REFRESH_SECRET=mH50eOHDoJu3Ay6KQPt1IRdI9yED5P1sajq7LamFhiCRs51kcJvsg4azdjf8eq2W +JWT_REFRESH_EXPIRES_IN=30d + +WEBHOOK_VERIFY_TOKEN=iUFaqbqv98giFiYHGl1vcVQZRWGFKBHuewMZiHXufYU30uWE+TlC27pn/Ln2vtis +WEBHOOK_SHARED_SECRET=CPK2/u9Gb/1pcsJL/jGbZA1N+ohEuL3l3T8mxZuyI4cIZtqnKW8QIfyguGD+nMMa +META_WEBHOOK_APP_SECRET=replace-with-meta-app-secret +WEBHOOK_ALLOW_UNSIGNED=false + +MAIL_HOST=mail.bizone.id +MAIL_PORT=465 +MAIL_SECURE=true +MAIL_USER=no-reply@bizone.id +MAIL_PASSWORD=62FwN86$3Y~#utQ@ +MAIL_FROM=Bizone Portal + +AUTH_LOGIN_MAX_ATTEMPTS=5 +AUTH_LOGIN_WINDOW_MINUTES=15 +AUTH_2FA_MAX_ATTEMPTS=5 +AUTH_2FA_WINDOW_MINUTES=10 +AUTH_PASSWORD_RESET_MAX_ATTEMPTS=3 +AUTH_PASSWORD_RESET_WINDOW_MINUTES=30 diff --git a/deploy/debian12/bizone-backend.service b/deploy/debian12/bizone-backend.service new file mode 100644 index 0000000..da37671 --- /dev/null +++ b/deploy/debian12/bizone-backend.service @@ -0,0 +1,18 @@ +[Unit] +Description=Bizone Backend API +After=network.target docker.service +Requires=docker.service + +[Service] +Type=simple +User=bizone +Group=bizone +WorkingDirectory=/srv/bizone-web/backend +EnvironmentFile=/srv/bizone-web/.env +ExecStart=/usr/bin/npm run start:prod +Restart=always +RestartSec=5 +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/debian12/bizone-frontend.service b/deploy/debian12/bizone-frontend.service new file mode 100644 index 0000000..0806f53 --- /dev/null +++ b/deploy/debian12/bizone-frontend.service @@ -0,0 +1,18 @@ +[Unit] +Description=Bizone Frontend +After=network.target bizone-backend.service +Requires=bizone-backend.service + +[Service] +Type=simple +User=bizone +Group=bizone +WorkingDirectory=/srv/bizone-web/frontend +EnvironmentFile=/srv/bizone-web/.env +ExecStart=/usr/bin/npm run start +Restart=always +RestartSec=5 +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/debian12/docker-compose.infra.yml b/deploy/debian12/docker-compose.infra.yml new file mode 100644 index 0000000..1f55d9f --- /dev/null +++ b/deploy/debian12/docker-compose.infra.yml @@ -0,0 +1,29 @@ +version: "3.9" + +services: + postgres: + image: postgres:16 + container_name: bizone-postgres + restart: unless-stopped + environment: + POSTGRES_DB: wa_dashboard + POSTGRES_USER: bizone + POSTGRES_PASSWORD: change-this-postgres-password + ports: + - "127.0.0.1:5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7 + container_name: bizone-redis + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + ports: + - "127.0.0.1:6379:6379" + volumes: + - redis_data:/data + +volumes: + postgres_data: + redis_data: diff --git a/deploy/debian12/nginx.portal.bizone.id.conf b/deploy/debian12/nginx.portal.bizone.id.conf new file mode 100644 index 0000000..39c55fd --- /dev/null +++ b/deploy/debian12/nginx.portal.bizone.id.conf @@ -0,0 +1,27 @@ +server { + listen 80; + listen [::]:80; + server_name portal.bizone.id; + + client_max_body_size 20m; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location /api/ { + proxy_pass http://127.0.0.1:3001/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4d7dd59 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.9' +services: + postgres: + image: postgres:16 + environment: + POSTGRES_DB: wa_dashboard + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + redis: + image: redis:7 + ports: + - "6379:6379" + +volumes: + pgdata: diff --git a/docs/notes.md b/docs/notes.md new file mode 100644 index 0000000..345edab --- /dev/null +++ b/docs/notes.md @@ -0,0 +1,18 @@ +# Version 3 Notes + +This version is a runnable baseline, not a production-ready app yet. + +## Already included +- JWT login skeleton +- Contacts list/create skeleton via Prisma +- Webhook receive endpoint that stores payloads +- Docker compose for PostgreSQL and Redis +- Frontend dashboard layout skeleton + +## Next recommended work +- Password hashing and real auth guard +- Full CRUD for contacts +- Template and campaign modules +- Queue worker with BullMQ +- Provider adapter layer +- Better frontend UI kit integration diff --git a/docs/setup-notes.md b/docs/setup-notes.md new file mode 100644 index 0000000..e3404d5 --- /dev/null +++ b/docs/setup-notes.md @@ -0,0 +1,13 @@ +# Setup Notes + +## V6 improvements +- Login page sudah ada +- Demo token helper dipakai frontend agar alur fetch lebih kebayang +- Contacts page sudah punya form skeleton + list fetch +- Auth guard backend sudah return unauthorized kalau header tidak ada + +## Next recommended step +- Simpan token beneran via cookie/session +- Login form submit beneran +- Contacts form POST beneran +- JWT verification real, bukan check header doang diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..821f074 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY src ./src +EXPOSE 3000 +CMD ["npm", "run", "dev"] diff --git a/frontend/middleware.ts b/frontend/middleware.ts new file mode 100644 index 0000000..0985dbe --- /dev/null +++ b/frontend/middleware.ts @@ -0,0 +1,158 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; +const AUTH_COOKIE = 'wa_session'; +const REFRESH_COOKIE = 'wa_refresh'; +const SESSION_REFRESH_LEEWAY_SECONDS = 60; + +type RefreshPayload = { + access_token: string; + refresh_token: string; + access_token_max_age_seconds?: number; + refresh_token_max_age_seconds?: number; +}; + +function isProtectedPath(pathname: string) { + if (pathname.startsWith('/dashboard')) { + return true; + } + + if (!pathname.startsWith('/api')) { + return false; + } + + if (pathname.startsWith('/api/auth/password-reset')) { + return false; + } + + if (pathname.startsWith('/api/invitations/')) { + return false; + } + + return true; +} + +function decodeJwtExpiry(token: string) { + try { + const parts = token.split('.'); + if (parts.length < 2) { + return null; + } + + const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + const payload = JSON.parse(atob(padded)) as { exp?: unknown }; + return typeof payload.exp === 'number' ? payload.exp : null; + } catch { + return null; + } +} + +function tokenNeedsRefresh(token: string) { + const expiry = decodeJwtExpiry(token); + if (!expiry) { + return true; + } + + return expiry * 1000 <= Date.now() + SESSION_REFRESH_LEEWAY_SECONDS * 1000; +} + +function buildRequestCookieHeader(request: NextRequest, payload: RefreshPayload) { + const pairs = request.cookies.getAll().map((cookie) => [cookie.name, cookie.value] as const); + const nextCookies = new Map(pairs); + nextCookies.set(AUTH_COOKIE, payload.access_token); + nextCookies.set(REFRESH_COOKIE, payload.refresh_token); + + return Array.from(nextCookies.entries()) + .map(([name, value]) => `${name}=${value}`) + .join('; '); +} + +function applySessionCookies(response: NextResponse, payload: RefreshPayload) { + const secure = process.env.NODE_ENV === 'production'; + + response.cookies.set(AUTH_COOKIE, payload.access_token, { + httpOnly: true, + sameSite: 'lax', + secure, + path: '/', + maxAge: payload.access_token_max_age_seconds || 60 * 60 * 24, + }); + response.cookies.set(REFRESH_COOKIE, payload.refresh_token, { + httpOnly: true, + sameSite: 'lax', + secure, + path: '/', + maxAge: payload.refresh_token_max_age_seconds || 60 * 60 * 24 * 30, + }); +} + +function clearSessionCookies(response: NextResponse) { + response.cookies.delete(AUTH_COOKIE); + response.cookies.delete(REFRESH_COOKIE); +} + +function unauthorizedResponse(request: NextRequest) { + if (request.nextUrl.pathname.startsWith('/api')) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + return NextResponse.redirect(new URL('/login', request.url)); +} + +async function refreshSession(refreshToken: string) { + const response = await fetch(`${API_URL}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + cache: 'no-store', + }); + + if (!response.ok) { + return null; + } + + return (await response.json()) as RefreshPayload; +} + +export async function middleware(request: NextRequest) { + if (!isProtectedPath(request.nextUrl.pathname)) { + return NextResponse.next(); + } + + const accessToken = request.cookies.get(AUTH_COOKIE)?.value; + const refreshToken = request.cookies.get(REFRESH_COOKIE)?.value; + + if (accessToken && !tokenNeedsRefresh(accessToken)) { + return NextResponse.next(); + } + + if (!refreshToken) { + const response = unauthorizedResponse(request); + clearSessionCookies(response); + return response; + } + + const payload = await refreshSession(refreshToken); + if (!payload?.access_token || !payload.refresh_token) { + const response = unauthorizedResponse(request); + clearSessionCookies(response); + return response; + } + + const requestHeaders = new Headers(request.headers); + requestHeaders.set('cookie', buildRequestCookieHeader(request, payload)); + + const response = NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); + + applySessionCookies(response, payload); + return response; +} + +export const config = { + matcher: ['/dashboard/:path*', '/api/:path*'], +}; diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e2e93e1 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1285 @@ +{ + "name": "wa-dashboard-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wa-dashboard-frontend", + "version": "1.0.0", + "dependencies": { + "@types/qrcode": "^1.5.6", + "next": "15.0.0", + "qrcode": "^1.5.4", + "react": "19.0.0-rc-65a56d0e-20241020", + "react-dom": "19.0.0-rc-65a56d0e-20241020" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "typescript": "^5.7.2" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.0.0.tgz", + "integrity": "sha512-Mcv8ZVmEgTO3bePiH/eJ7zHqQEs2gCqZ0UId2RxHmDDc7Pw6ngfSrOFlxG8XDpaex+n2G+TKPsQAf28MO+88Gw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.0.0.tgz", + "integrity": "sha512-Gjgs3N7cFa40a9QT9AEHnuGKq69/bvIOn0SLGDV+ordq07QOP4k1GDOVedMHEjVeqy1HBLkL8rXnNTuMZIv79A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.0.0.tgz", + "integrity": "sha512-BUtTvY5u9s5berAuOEydAUlVMjnl6ZjXS+xVrMt317mglYZ2XXjY8YRDCaz9vYMjBNPXH8Gh75Cew5CMdVbWTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.0.0.tgz", + "integrity": "sha512-sbCoEpuWUBpYoLSgYrk0CkBv8RFv4ZlPxbwqRHr/BWDBJppTBtF53EvsntlfzQJ9fosYX12xnS6ltxYYwsMBjg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.0.0.tgz", + "integrity": "sha512-JAw84qfL81aQCirXKP4VkgmhiDpXJupGjt8ITUkHrOVlBd+3h5kjfPva5M0tH2F9KKSgJQHEo3F5S5tDH9h2ww==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.0.0.tgz", + "integrity": "sha512-r5Smd03PfxrGKMewdRf2RVNA1CU5l2rRlvZLQYZSv7FUsXD5bKEcOZ/6/98aqRwL7diXOwD8TCWJk1NbhATQHg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.0.0.tgz", + "integrity": "sha512-fM6qocafz4Xjhh79CuoQNeGPhDHGBBUbdVtgNFJOUM8Ih5ZpaDZlTvqvqsh5IoO06CGomxurEGqGz/4eR/FaMQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.0.0.tgz", + "integrity": "sha512-ZOd7c/Lz1lv7qP/KzR513XEa7QzW5/P0AH3A5eR1+Z/KmDOvMucht0AozccPc0TqhdV1xaXmC0Fdx0hoNzk6ng==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.0.0.tgz", + "integrity": "sha512-2RVWcLtsqg4LtaoJ3j7RoKpnWHgcrz5XvuUGE7vBYU2i6M2XeD9Y8RlLaF770LEIScrrl8MdWsp6odtC6sZccg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz", + "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", + "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT", + "optional": true + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/next/-/next-15.0.0.tgz", + "integrity": "sha512-/ivqF6gCShXpKwY9hfrIQYh8YMge8L3W+w1oRLv/POmK4MOQnh+FscZ8a0fRFTSQWE+2z9ctNYvELD9vP2FV+A==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", + "license": "MIT", + "dependencies": { + "@next/env": "15.0.0", + "@swc/counter": "0.1.3", + "@swc/helpers": "0.5.13", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.18.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.0.0", + "@next/swc-darwin-x64": "15.0.0", + "@next/swc-linux-arm64-gnu": "15.0.0", + "@next/swc-linux-arm64-musl": "15.0.0", + "@next/swc-linux-x64-gnu": "15.0.0", + "@next/swc-linux-x64-musl": "15.0.0", + "@next/swc-win32-arm64-msvc": "15.0.0", + "@next/swc-win32-x64-msvc": "15.0.0", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-65a56d0e-20241020", + "react-dom": "^18.2.0 || 19.0.0-rc-65a56d0e-20241020", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/react": { + "version": "19.0.0-rc-65a56d0e-20241020", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0-rc-65a56d0e-20241020.tgz", + "integrity": "sha512-rZqpfd9PP/A97j9L1MR6fvWSMgs3khgIyLd0E+gYoCcLrxXndj+ySPRVlDPDC3+f7rm8efHNL4B6HeapqU6gzw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.0.0-rc-65a56d0e-20241020", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0-rc-65a56d0e-20241020.tgz", + "integrity": "sha512-OrsgAX3LQ6JtdBJayK4nG1Hj5JebzWyhKSsrP/bmkeFxulb0nG2LaPloJ6kBkAxtgjiwRyGUciJ4+Qu64gy/KA==", + "license": "MIT", + "dependencies": { + "scheduler": "0.25.0-rc-65a56d0e-20241020" + }, + "peerDependencies": { + "react": "19.0.0-rc-65a56d0e-20241020" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/scheduler": { + "version": "0.25.0-rc-65a56d0e-20241020", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-65a56d0e-20241020.tgz", + "integrity": "sha512-HxWcXSy0sNnf+TKRkMwyVD1z19AAVQ4gUub8m7VxJUUfSu3J4lr1T+AagohKEypiW5dbQhJuCtAumPY6z9RQ1g==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e01d79b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "wa-dashboard-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start -p 3000 -H 127.0.0.1" + }, + "dependencies": { + "@types/qrcode": "^1.5.6", + "next": "15.0.0", + "qrcode": "^1.5.4", + "react": "19.0.0-rc-65a56d0e-20241020", + "react-dom": "19.0.0-rc-65a56d0e-20241020" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "typescript": "^5.7.2" + } +} diff --git a/frontend/src/app/actions.ts b/frontend/src/app/actions.ts new file mode 100644 index 0000000..2172fb9 --- /dev/null +++ b/frontend/src/app/actions.ts @@ -0,0 +1,409 @@ +'use server'; + +import { cookies } from 'next/headers'; +import { revalidatePath } from 'next/cache'; +import { redirect } from 'next/navigation'; +import { + authCookieName, + refreshCookieName, + twoFactorChallengeCookieName, + twoFactorEmailCookieName, +} from '../lib/auth'; +import { defaultLocale, isLocale, localeCookieName } from '../lib/i18n'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; +const secureCookies = process.env.NODE_ENV === 'production'; + +type FormState = { + error?: string; + success?: string; + manualEntryKey?: string; + otpauthUrl?: string; + qrCodeDataUrl?: string; + recoveryCodes?: string[]; +}; + +function getErrorMessage(payload: unknown, fallback: string) { + if (payload && typeof payload === 'object') { + const message = (payload as { message?: unknown }).message; + if (typeof message === 'string') { + return message; + } + + if (Array.isArray(message)) { + return message.join(', '); + } + } + + return fallback; +} + +async function buildQrCodeDataUrl(value: string) { + const { toDataURL } = await import('qrcode'); + return toDataURL(value, { + errorCorrectionLevel: 'M', + margin: 1, + width: 220, + }); +} + +export async function loginAction(_: FormState, formData: FormData): Promise { + const email = String(formData.get('email') || '').trim(); + const password = String(formData.get('password') || ''); + + const response = await fetch(`${API_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + cache: 'no-store', + }); + + const payload = await response.json(); + + if (!response.ok) { + return { error: payload.message || 'Login failed' }; + } + + if (payload.requiresTwoFactor && payload.challengeToken) { + const cookieStore = await cookies(); + cookieStore.set(twoFactorChallengeCookieName, payload.challengeToken, { + httpOnly: true, + sameSite: 'lax', + secure: secureCookies, + path: '/', + maxAge: 60 * 10, + }); + cookieStore.set(twoFactorEmailCookieName, payload.user?.email || email, { + httpOnly: true, + sameSite: 'lax', + secure: secureCookies, + path: '/', + maxAge: 60 * 10, + }); + redirect('/login/2fa'); + } + + const cookieStore = await cookies(); + cookieStore.set(authCookieName, payload.access_token, { + httpOnly: true, + sameSite: 'lax', + secure: secureCookies, + path: '/', + maxAge: payload.access_token_max_age_seconds || 60 * 60 * 24, + }); + cookieStore.set(refreshCookieName, payload.refresh_token, { + httpOnly: true, + sameSite: 'lax', + secure: secureCookies, + path: '/', + maxAge: payload.refresh_token_max_age_seconds || 60 * 60 * 24 * 30, + }); + + redirect('/dashboard'); +} + +export async function logoutAction() { + const cookieStore = await cookies(); + const accessToken = cookieStore.get(authCookieName)?.value; + const refreshToken = cookieStore.get(refreshCookieName)?.value; + + if (accessToken || refreshToken) { + await fetch(`${API_URL}/auth/logout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + body: JSON.stringify({ refreshToken }), + cache: 'no-store', + }).catch(() => undefined); + } + + cookieStore.delete(authCookieName); + cookieStore.delete(refreshCookieName); + cookieStore.delete(twoFactorChallengeCookieName); + cookieStore.delete(twoFactorEmailCookieName); + redirect('/login'); +} + +export async function verifyTwoFactorLoginAction(_: FormState, formData: FormData): Promise { + const code = String(formData.get('code') || '').trim(); + const cookieStore = await cookies(); + const challengeToken = cookieStore.get(twoFactorChallengeCookieName)?.value; + + if (!challengeToken) { + return { error: 'Two-factor login session expired. Please log in again.' }; + } + + const response = await fetch(`${API_URL}/auth/2fa/login/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ challengeToken, code }), + cache: 'no-store', + }); + + const payload = await response.json(); + if (!response.ok) { + return { error: getErrorMessage(payload, 'Failed to verify two-factor code') }; + } + + cookieStore.set(authCookieName, payload.access_token, { + httpOnly: true, + sameSite: 'lax', + secure: secureCookies, + path: '/', + maxAge: payload.access_token_max_age_seconds || 60 * 60 * 24, + }); + cookieStore.set(refreshCookieName, payload.refresh_token, { + httpOnly: true, + sameSite: 'lax', + secure: secureCookies, + path: '/', + maxAge: payload.refresh_token_max_age_seconds || 60 * 60 * 24 * 30, + }); + cookieStore.delete(twoFactorChallengeCookieName); + cookieStore.delete(twoFactorEmailCookieName); + redirect('/dashboard'); +} + +async function requireServerAuthCookie() { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + redirect('/login'); + } + + return token; +} + +export async function initiateTwoFactorSetupAction(_: FormState): Promise { + const token = await requireServerAuthCookie(); + const response = await fetch(`${API_URL}/auth/2fa/setup/initiate`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + const payload = await response.json(); + if (!response.ok) { + return { error: getErrorMessage(payload, 'Failed to start two-factor setup') }; + } + + revalidatePath('/dashboard/settings/security'); + return { + success: 'Two-factor setup initialized.', + manualEntryKey: payload.manualEntryKey, + otpauthUrl: payload.otpauthUrl, + qrCodeDataUrl: await buildQrCodeDataUrl(payload.otpauthUrl), + }; +} + +export async function confirmTwoFactorSetupAction(_: FormState, formData: FormData): Promise { + const token = await requireServerAuthCookie(); + const code = String(formData.get('code') || '').trim(); + const response = await fetch(`${API_URL}/auth/2fa/setup/confirm`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ code }), + cache: 'no-store', + }); + + const payload = await response.json(); + if (!response.ok) { + return { error: getErrorMessage(payload, 'Failed to confirm two-factor setup') }; + } + + revalidatePath('/dashboard/settings/security'); + return { + success: 'Two-factor authentication enabled.', + recoveryCodes: Array.isArray(payload.recoveryCodes) ? payload.recoveryCodes : [], + }; +} + +export async function disableTwoFactorAction(_: FormState, formData: FormData): Promise { + const token = await requireServerAuthCookie(); + const code = String(formData.get('code') || '').trim(); + const response = await fetch(`${API_URL}/auth/2fa/disable`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ code }), + cache: 'no-store', + }); + + const payload = await response.json(); + if (!response.ok) { + return { error: getErrorMessage(payload, 'Failed to disable two-factor authentication') }; + } + + revalidatePath('/dashboard/settings/security'); + return { success: 'Two-factor authentication disabled.' }; +} + +export async function regenerateTwoFactorRecoveryCodesAction( + _: FormState, + formData: FormData, +): Promise { + const token = await requireServerAuthCookie(); + const code = String(formData.get('code') || '').trim(); + const response = await fetch(`${API_URL}/auth/2fa/recovery-codes/regenerate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ code }), + cache: 'no-store', + }); + + const payload = await response.json(); + if (!response.ok) { + return { error: getErrorMessage(payload, 'Failed to regenerate recovery codes') }; + } + + revalidatePath('/dashboard/settings/security'); + return { + success: 'Recovery codes regenerated.', + recoveryCodes: Array.isArray(payload.recoveryCodes) ? payload.recoveryCodes : [], + }; +} + +export async function setLocaleAction(formData: FormData) { + const nextLocale = String(formData.get('locale') || ''); + const redirectPath = String(formData.get('redirectPath') || '/'); + + const cookieStore = await cookies(); + cookieStore.set(localeCookieName, isLocale(nextLocale) ? nextLocale : defaultLocale, { + httpOnly: true, + sameSite: 'lax', + secure: secureCookies, + path: '/', + maxAge: 60 * 60 * 24 * 365, + }); + + redirect(redirectPath); +} + +export async function createContactAction(_: FormState, formData: FormData): Promise { + const token = await requireServerAuthCookie(); + + const payload = { + name: String(formData.get('name') || '').trim(), + phoneNumber: String(formData.get('phoneNumber') || '').trim(), + email: String(formData.get('email') || '').trim() || undefined, + company: String(formData.get('company') || '').trim() || undefined, + notes: String(formData.get('notes') || '').trim() || undefined, + }; + + const response = await fetch(`${API_URL}/contacts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + cache: 'no-store', + }); + + const result = await response.json(); + if (!response.ok) { + return { error: getErrorMessage(result, 'Failed to create contact') }; + } + + revalidatePath('/dashboard/contacts'); + return { success: 'ok' }; +} + +export async function updateWhatsappSettingsAction(_: FormState, formData: FormData): Promise { + const token = await requireServerAuthCookie(); + + const payload = { + provider: String(formData.get('provider') || '').trim() || undefined, + webhookVerifyToken: String(formData.get('webhookVerifyToken') || '').trim() || undefined, + sharedSecret: String(formData.get('sharedSecret') || '').trim() || undefined, + appSecret: String(formData.get('appSecret') || '').trim() || undefined, + accessToken: String(formData.get('accessToken') || '').trim() || undefined, + phoneNumberId: String(formData.get('phoneNumberId') || '').trim() || undefined, + isEnabled: String(formData.get('isEnabled') || '') === 'on', + subscriptions: formData + .getAll('subscriptions') + .map((item) => String(item).trim()) + .filter(Boolean), + }; + + const response = await fetch(`${API_URL}/integrations/whatsapp`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + cache: 'no-store', + }); + + const result = await response.json(); + if (!response.ok) { + return { error: getErrorMessage(result, 'Failed to update WhatsApp settings') }; + } + + revalidatePath('/dashboard/settings/whatsapp-api'); + return { success: 'saved' }; +} + +export async function testWhatsappSettingsAction(_: FormState, formData: FormData): Promise { + const token = await requireServerAuthCookie(); + + const payload = { + provider: String(formData.get('provider') || '').trim() || undefined, + senderPhone: String(formData.get('senderPhone') || '').trim() || undefined, + }; + + const response = await fetch(`${API_URL}/integrations/whatsapp/test`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + cache: 'no-store', + }); + + const result = await response.json(); + if (!response.ok) { + return { error: getErrorMessage(result, 'Failed to queue webhook test') }; + } + + revalidatePath('/dashboard/settings/webhook-logs'); + return { success: `queued:${result.eventId || 'test-event'}` }; +} + +export async function retryWebhookEventAction(_: FormState, formData: FormData): Promise { + const token = await requireServerAuthCookie(); + + const eventId = String(formData.get('eventId') || '').trim(); + if (!eventId) { + return { error: 'Missing event id' }; + } + + const response = await fetch(`${API_URL}/webhooks/logs/${eventId}/retry`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + const result = await response.json(); + if (!response.ok) { + return { error: getErrorMessage(result, 'Failed to retry webhook event') }; + } + + revalidatePath('/dashboard/settings/webhook-logs'); + return { success: `requeued:${eventId}` }; +} diff --git a/frontend/src/app/api/audit-trail/export/route.ts b/frontend/src/app/api/audit-trail/export/route.ts new file mode 100644 index 0000000..3526850 --- /dev/null +++ b/frontend/src/app/api/audit-trail/export/route.ts @@ -0,0 +1,29 @@ +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function GET(request: Request) { + const token = (await cookies()).get(authCookieName)?.value; + + if (!token) { + return new Response('Unauthorized', { status: 401 }); + } + + const url = new URL(request.url); + const response = await fetch(`${API_URL}/logs/audit-trail/export${url.search}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + const csv = await response.text(); + return new Response(csv, { + status: response.status, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="audit-trail-export.csv"', + }, + }); +} diff --git a/frontend/src/app/api/audit-trail/route.ts b/frontend/src/app/api/audit-trail/route.ts new file mode 100644 index 0000000..0e4feaa --- /dev/null +++ b/frontend/src/app/api/audit-trail/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function GET(request: Request) { + const token = (await cookies()).get(authCookieName)?.value; + + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const url = new URL(request.url); + const response = await fetch(`${API_URL}/logs/audit-trail${url.search}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} + +export async function HEAD() { + return NextResponse.json({}, { status: 405 }); +} + +export async function POST(request: Request) { + const token = (await cookies()).get(authCookieName)?.value; + + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const response = await fetch(`${API_URL}/logs/audit-trail`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/auth/password-reset/[token]/route.ts b/frontend/src/app/api/auth/password-reset/[token]/route.ts new file mode 100644 index 0000000..4d904f6 --- /dev/null +++ b/frontend/src/app/api/auth/password-reset/[token]/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ token: string }> }, +) { + const { token } = await params; + const response = await fetch(`${API_URL}/auth/password-reset/${token}`, { + cache: 'no-store', + }); + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ token: string }> }, +) { + const { token } = await params; + const body = await request.json(); + const response = await fetch(`${API_URL}/auth/password-reset/${token}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + cache: 'no-store', + }); + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/auth/password-reset/route.ts b/frontend/src/app/api/auth/password-reset/route.ts new file mode 100644 index 0000000..3283a50 --- /dev/null +++ b/frontend/src/app/api/auth/password-reset/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function POST(request: Request) { + const body = await request.json(); + const response = await fetch(`${API_URL}/auth/forgot-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + cache: 'no-store', + }); + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/campaigns/[id]/duplicate/route.ts b/frontend/src/app/api/campaigns/[id]/duplicate/route.ts new file mode 100644 index 0000000..a334037 --- /dev/null +++ b/frontend/src/app/api/campaigns/[id]/duplicate/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const response = await fetch(`${API_URL}/campaigns/${id}/duplicate`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/campaigns/[id]/export/route.ts b/frontend/src/app/api/campaigns/[id]/export/route.ts new file mode 100644 index 0000000..ab2f63a --- /dev/null +++ b/frontend/src/app/api/campaigns/[id]/export/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const url = new URL(request.url); + const format = url.searchParams.get('format') === 'xlsx' ? 'xlsx' : 'csv'; + + const response = await fetch(`${API_URL}/campaigns/${id}/export?format=${format}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + const buffer = await response.arrayBuffer(); + const headers = new Headers(); + headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream'); + headers.set( + 'Content-Disposition', + response.headers.get('Content-Disposition') || `attachment; filename="campaign-report.${format}"`, + ); + + return new NextResponse(buffer, { + status: response.status, + headers, + }); +} diff --git a/frontend/src/app/api/campaigns/[id]/route.ts b/frontend/src/app/api/campaigns/[id]/route.ts new file mode 100644 index 0000000..3bfc527 --- /dev/null +++ b/frontend/src/app/api/campaigns/[id]/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +async function getToken() { + return (await cookies()).get(authCookieName)?.value; +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const token = await getToken(); + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const response = await fetch(`${API_URL}/campaigns/${id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} + +export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) { + const token = await getToken(); + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const response = await fetch(`${API_URL}/campaigns/${id}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + if (response.status === 204) { + return new NextResponse(null, { status: 204 }); + } + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/campaigns/[id]/send/route.ts b/frontend/src/app/api/campaigns/[id]/send/route.ts new file mode 100644 index 0000000..8cf0d1f --- /dev/null +++ b/frontend/src/app/api/campaigns/[id]/send/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const response = await fetch(`${API_URL}/campaigns/${id}/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/campaigns/route.ts b/frontend/src/app/api/campaigns/route.ts new file mode 100644 index 0000000..ac06070 --- /dev/null +++ b/frontend/src/app/api/campaigns/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function POST(request: Request) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const response = await fetch(`${API_URL}/campaigns`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/contacts/[id]/route.ts b/frontend/src/app/api/contacts/[id]/route.ts new file mode 100644 index 0000000..3cf2369 --- /dev/null +++ b/frontend/src/app/api/contacts/[id]/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +async function getToken() { + return (await cookies()).get(authCookieName)?.value; +} + +export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) { + const token = await getToken(); + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const response = await fetch(`${API_URL}/contacts/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const token = await getToken(); + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const response = await fetch(`${API_URL}/contacts/${id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} + +export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) { + const token = await getToken(); + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const response = await fetch(`${API_URL}/contacts/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/contacts/export/route.ts b/frontend/src/app/api/contacts/export/route.ts new file mode 100644 index 0000000..283cba2 --- /dev/null +++ b/frontend/src/app/api/contacts/export/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function GET(request: Request) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const url = new URL(request.url); + const response = await fetch(`${API_URL}/contacts/export${url.search ? url.search : ''}`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }); + + const buffer = await response.arrayBuffer(); + const headers = new Headers(); + headers.set('Content-Type', response.headers.get('Content-Type') || 'text/csv; charset=utf-8'); + headers.set( + 'Content-Disposition', + response.headers.get('Content-Disposition') || 'attachment; filename="contacts-directory.csv"', + ); + + return new NextResponse(buffer, { + status: response.status, + headers, + }); +} diff --git a/frontend/src/app/api/contacts/route.ts b/frontend/src/app/api/contacts/route.ts new file mode 100644 index 0000000..8ef2ee0 --- /dev/null +++ b/frontend/src/app/api/contacts/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +function buildUrl(searchParams: URLSearchParams) { + const query = searchParams.toString(); + return `${API_URL}/contacts${query ? `?${query}` : ''}`; +} + +export async function GET(request: Request) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const url = new URL(request.url); + const response = await fetch(buildUrl(url.searchParams), { + headers: { + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} + +export async function POST(request: Request) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const response = await fetch(`${API_URL}/contacts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/conversations/[id]/assign/route.ts b/frontend/src/app/api/conversations/[id]/assign/route.ts new file mode 100644 index 0000000..be08bad --- /dev/null +++ b/frontend/src/app/api/conversations/[id]/assign/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +async function getToken() { + return (await cookies()).get(authCookieName)?.value; +} + +export async function POST(_: Request, { params }: { params: Promise<{ id: string }> }) { + const token = await getToken(); + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const response = await fetch(`${API_URL}/conversations/${id}/assign`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/conversations/[id]/messages/route.ts b/frontend/src/app/api/conversations/[id]/messages/route.ts new file mode 100644 index 0000000..a979718 --- /dev/null +++ b/frontend/src/app/api/conversations/[id]/messages/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +async function getToken() { + return (await cookies()).get(authCookieName)?.value; +} + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const token = await getToken(); + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const response = await fetch(`${API_URL}/conversations/${id}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/conversations/[id]/route.ts b/frontend/src/app/api/conversations/[id]/route.ts new file mode 100644 index 0000000..07ca130 --- /dev/null +++ b/frontend/src/app/api/conversations/[id]/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +async function getToken() { + return (await cookies()).get(authCookieName)?.value; +} + +export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) { + const token = await getToken(); + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const response = await fetch(`${API_URL}/conversations/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/conversations/route.ts b/frontend/src/app/api/conversations/route.ts new file mode 100644 index 0000000..99660fd --- /dev/null +++ b/frontend/src/app/api/conversations/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +function buildUrl(searchParams: URLSearchParams) { + const query = searchParams.toString(); + return `${API_URL}/conversations${query ? `?${query}` : ''}`; +} + +export async function GET(request: Request) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const url = new URL(request.url); + const response = await fetch(buildUrl(url.searchParams), { + headers: { + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/invitations/[token]/complete/route.ts b/frontend/src/app/api/invitations/[token]/complete/route.ts new file mode 100644 index 0000000..2b4dfd4 --- /dev/null +++ b/frontend/src/app/api/invitations/[token]/complete/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ token: string }> }, +) { + const { token } = await params; + const body = await request.json(); + const response = await fetch(`${API_URL}/users/invitations/${token}/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + cache: 'no-store', + }); + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/invitations/[token]/route.ts b/frontend/src/app/api/invitations/[token]/route.ts new file mode 100644 index 0000000..5a15f8f --- /dev/null +++ b/frontend/src/app/api/invitations/[token]/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ token: string }> }, +) { + const { token } = await params; + const response = await fetch(`${API_URL}/users/invitations/${token}`, { + cache: 'no-store', + }); + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/roles/[id]/route.ts b/frontend/src/app/api/roles/[id]/route.ts new file mode 100644 index 0000000..37c25ab --- /dev/null +++ b/frontend/src/app/api/roles/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const response = await fetch(`${API_URL}/roles/${id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/roles/route.ts b/frontend/src/app/api/roles/route.ts new file mode 100644 index 0000000..be40422 --- /dev/null +++ b/frontend/src/app/api/roles/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function GET() { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const response = await fetch(`${API_URL}/roles`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }); + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} + +export async function POST(request: Request) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const response = await fetch(`${API_URL}/roles`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/templates/[id]/route.ts b/frontend/src/app/api/templates/[id]/route.ts new file mode 100644 index 0000000..0867a34 --- /dev/null +++ b/frontend/src/app/api/templates/[id]/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +async function getToken() { + return (await cookies()).get(authCookieName)?.value; +} + +export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) { + const token = await getToken(); + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const response = await fetch(`${API_URL}/templates/${id}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const token = await getToken(); + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const response = await fetch(`${API_URL}/templates/${id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/templates/route.ts b/frontend/src/app/api/templates/route.ts new file mode 100644 index 0000000..1fd99d8 --- /dev/null +++ b/frontend/src/app/api/templates/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +async function getToken() { + return (await cookies()).get(authCookieName)?.value; +} + +export async function GET(request: Request) { + const token = await getToken(); + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const url = new URL(request.url); + const response = await fetch(`${API_URL}/templates${url.search}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} + +export async function POST(request: Request) { + const token = await getToken(); + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const response = await fetch(`${API_URL}/templates`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/users/[id]/route.ts b/frontend/src/app/api/users/[id]/route.ts new file mode 100644 index 0000000..35e4666 --- /dev/null +++ b/frontend/src/app/api/users/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const response = await fetch(`${API_URL}/users/${id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/api/users/export/route.ts b/frontend/src/app/api/users/export/route.ts new file mode 100644 index 0000000..41e18c5 --- /dev/null +++ b/frontend/src/app/api/users/export/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function GET(request: Request) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const url = new URL(request.url); + const response = await fetch(`${API_URL}/users/export${url.search}`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }); + const csv = await response.text(); + + return new NextResponse(csv, { + status: response.status, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="users-export.csv"', + }, + }); +} diff --git a/frontend/src/app/api/users/route.ts b/frontend/src/app/api/users/route.ts new file mode 100644 index 0000000..45c5e0e --- /dev/null +++ b/frontend/src/app/api/users/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { authCookieName } from '../../../lib/auth'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export async function GET(request: Request) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const url = new URL(request.url); + const response = await fetch(`${API_URL}/users${url.search}`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }); + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} + +export async function POST(request: Request) { + const token = (await cookies()).get(authCookieName)?.value; + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const response = await fetch(`${API_URL}/users/invite`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + const payload = await response.json(); + return NextResponse.json(payload, { status: response.status }); +} diff --git a/frontend/src/app/dashboard/campaigns/[id]/page.tsx b/frontend/src/app/dashboard/campaigns/[id]/page.tsx new file mode 100644 index 0000000..d45f1ce --- /dev/null +++ b/frontend/src/app/dashboard/campaigns/[id]/page.tsx @@ -0,0 +1,313 @@ +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { CampaignDetailActions } from '../../../../components/campaign-detail-actions'; +import { DashboardShell } from '../../../../components/dashboard-shell'; +import { requireAuthToken } from '../../../../lib/auth'; +import { fetchCampaignDetail } from '../../../../lib/api'; + +function formatDateTime(value: string) { + const date = new Date(value); + return { + date: date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }), + time: date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }), + }; +} + +function formatRecipientTime(value: string | null) { + if (!value) { + return '—'; + } + + const date = new Date(value); + return `Today, ${date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })}`; +} + +function campaignStatusClassName(status: string) { + switch (status) { + case 'Sent': + return 'campaign-detail-badge is-sent'; + case 'Scheduled': + return 'campaign-detail-badge is-scheduled'; + case 'Draft': + return 'campaign-detail-badge is-draft'; + case 'Failed': + return 'campaign-detail-badge is-failed'; + default: + return 'campaign-detail-badge'; + } +} + +function recipientStatusClassName(status: string) { + switch (status) { + case 'Read': + return 'campaign-recipient-status is-read'; + case 'Delivered': + return 'campaign-recipient-status is-delivered'; + case 'Failed': + return 'campaign-recipient-status is-failed'; + default: + return 'campaign-recipient-status'; + } +} + +function deviceIcon(label: string) { + if (label === 'Android') return 'phone_android'; + if (label === 'iOS') return 'phone_iphone'; + return 'laptop'; +} + +export default async function CampaignDetailPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams?: Promise<{ page?: string }>; +}) { + const token = await requireAuthToken(); + const { id } = await params; + const query = await searchParams; + const page = Math.max(1, Number(query?.page || '1')); + + try { + const detail = await fetchCampaignDetail(token, id, { page, limit: 5 }); + const stamp = formatDateTime(detail.campaign.initiatedAt); + + return ( + +
    +
    +
    + +
    +

    {detail.campaign.name}

    + {detail.campaign.status} +
    +

    Initiated on {stamp.date} at {stamp.time}

    +
    + +
    + +
    +
    +
    + group + + trending_up + 12% + +
    +

    Total Recipients

    + {detail.campaign.totalRecipients.toLocaleString('en-US')} +
    + +
    +
    + mark_email_read + {detail.campaign.deliveredRate.toFixed(1)}% +
    +

    Delivered Rate

    + {detail.campaign.deliveredCount.toLocaleString('en-US')} +
    + +
    +
    + visibility + + trending_down + 4% + +
    +

    Read Rate

    + {detail.campaign.readRate.toFixed(1)}% +
    + +
    +
    + error + {detail.campaign.failedRate.toFixed(1)}% +
    +

    Failed Messages

    + {detail.campaign.failedCount.toLocaleString('en-US')} +
    +
    + +
    +
    +
    +

    Delivery Timeline

    + +
    +
    + {detail.timeline.map((bucket, index) => ( +
    +
    +
    + ))} +
    +
    + {detail.timeline.filter((_, index) => index % 2 === 0).map((bucket) => ( + {bucket.label} + ))} +
    +
    + +
    +

    Message Preview

    +
    + {detail.campaign.name} +

    {detail.campaign.messageTitle}

    +

    {detail.campaign.messageBody}

    +
    + Shop Now + {stamp.time} +
    +
    + + +
    +
    +
    +
    + Template Name + {detail.campaign.templateName} +
    +
    + Language + {detail.campaign.language} +
    +
    +
    +
    + +
    +
    +
    +

    Recipient List

    +
    + + +
    +
    + +
    + + + + + + + + + + + {detail.recipients.items.map((recipient) => ( + + + + + + + ))} + +
    Phone NumberStatusTimestampError Reason
    {recipient.phoneNumber} +
    + + {recipient.status === 'Failed' ? 'error' : 'done_all'} + + {recipient.status} +
    +
    {formatRecipientTime(recipient.sentAt)} + {recipient.errorReason ? ( + {recipient.errorReason} + ) : ( + '—' + )} +
    +
    + +
    +

    + Showing {(detail.recipients.page - 1) * detail.recipients.pageSize + 1} to{' '} + {(detail.recipients.page - 1) * detail.recipients.pageSize + detail.recipients.items.length} of{' '} + {detail.recipients.total.toLocaleString('en-US')} recipients +

    +
    + + chevron_left + + = detail.recipients.totalPages ? 'is-disabled' : ''} + aria-disabled={detail.recipients.page >= detail.recipients.totalPages} + > + chevron_right + +
    +
    +
    + + +
    +
    +
    + ); + } catch { + notFound(); + } +} diff --git a/frontend/src/app/dashboard/campaigns/page.tsx b/frontend/src/app/dashboard/campaigns/page.tsx new file mode 100644 index 0000000..a786585 --- /dev/null +++ b/frontend/src/app/dashboard/campaigns/page.tsx @@ -0,0 +1,15 @@ +import { CampaignsManagementBoard } from '../../../components/campaigns-management-board'; +import { DashboardShell } from '../../../components/dashboard-shell'; +import { requireAuthToken } from '../../../lib/auth'; +import { fetchCampaigns } from '../../../lib/api'; + +export default async function CampaignsPage() { + const token = await requireAuthToken(); + const campaignsData = await fetchCampaigns(token); + + return ( + + + + ); +} diff --git a/frontend/src/app/dashboard/contacts/[id]/page.tsx b/frontend/src/app/dashboard/contacts/[id]/page.tsx new file mode 100644 index 0000000..eca7df8 --- /dev/null +++ b/frontend/src/app/dashboard/contacts/[id]/page.tsx @@ -0,0 +1,23 @@ +import { notFound } from 'next/navigation'; +import { ContactDetailBoard } from '../../../../components/contact-detail-board'; +import { DashboardShell } from '../../../../components/dashboard-shell'; +import { requireAuthToken } from '../../../../lib/auth'; +import { fetchContactDetail } from '../../../../lib/api'; + +export default async function ContactDetailPage({ params }: { params: Promise<{ id: string }> }) { + const token = await requireAuthToken(); + const { id } = await params; + + let detail; + try { + detail = await fetchContactDetail(token, id); + } catch { + notFound(); + } + + return ( + + + + ); +} diff --git a/frontend/src/app/dashboard/contacts/page.tsx b/frontend/src/app/dashboard/contacts/page.tsx new file mode 100644 index 0000000..20c0184 --- /dev/null +++ b/frontend/src/app/dashboard/contacts/page.tsx @@ -0,0 +1,31 @@ +import { DashboardShell } from '../../../components/dashboard-shell'; +import { ContactsDirectoryBoard } from '../../../components/contacts-directory-board'; +import { requireAuthToken } from '../../../lib/auth'; +import { fetchContactsDirectory } from '../../../lib/api'; + +export default async function ContactsPage({ + searchParams, +}: { + searchParams?: Promise<{ + page?: string; + limit?: string; + search?: string; + status?: string; + tag?: string; + }>; +}) { + const token = await requireAuthToken(); + const query = await searchParams; + const page = Math.max(1, Number(query?.page || '1')); + const limit = Math.max(1, Number(query?.limit || '10')); + const search = query?.search || ''; + const status = query?.status || ''; + const tag = query?.tag || ''; + const data = await fetchContactsDirectory(token, { page, limit, search, status, tag }); + + return ( + + + + ); +} diff --git a/frontend/src/app/dashboard/conversations/page.tsx b/frontend/src/app/dashboard/conversations/page.tsx new file mode 100644 index 0000000..ca07709 --- /dev/null +++ b/frontend/src/app/dashboard/conversations/page.tsx @@ -0,0 +1,35 @@ +import { DashboardShell } from '../../../components/dashboard-shell'; +import { ConversationsInbox } from '../../../components/conversations-inbox'; +import { requireAuthToken } from '../../../lib/auth'; +import { fetchConversationDetail, fetchConversations } from '../../../lib/api'; + +export default async function ConversationsPage({ + searchParams, +}: { + searchParams?: Promise<{ q?: string }>; +}) { + const token = await requireAuthToken(); + const resolvedSearchParams = (await searchParams) || {}; + const search = resolvedSearchParams.q?.trim() || ''; + const conversations = await fetchConversations(token, { filter: 'all', search }); + const initialConversationDetail = conversations[0] + ? await fetchConversationDetail(token, conversations[0].id) + : null; + + return ( + + + + ); +} diff --git a/frontend/src/app/dashboard/logs/page.tsx b/frontend/src/app/dashboard/logs/page.tsx new file mode 100644 index 0000000..478a7d3 --- /dev/null +++ b/frontend/src/app/dashboard/logs/page.tsx @@ -0,0 +1,348 @@ +import { DashboardShell } from '../../../components/dashboard-shell'; +import { + fetchAnalyticsSummary, + fetchAuditTrail, +} from '../../../lib/api'; +import { requireAuthToken } from '../../../lib/auth'; +import { + analyticsLogs as fallbackAnalyticsLogs, + analyticsWorkerHealth as fallbackWorkerHealth, +} from '../../../lib/mock-data'; + +type StatusTone = 'success' | 'warning' | 'error'; + +function statusClassName(tone: StatusTone) { + if (tone === 'error') return 'analytics-status-pill is-error'; + if (tone === 'warning') return 'analytics-status-pill is-warning'; + return 'analytics-status-pill is-success'; +} + +function payloadButtonClassName(tone: 'primary' | 'success' | 'error' | 'neutral') { + if (tone === 'error') return 'analytics-payload-button is-error'; + if (tone === 'success') return 'analytics-payload-button is-success'; + if (tone === 'primary') return 'analytics-payload-button is-primary'; + return 'analytics-payload-button'; +} + +function formatTimestamp(value: string | null) { + if (!value) { + return 'Pending'; + } + + return new Date(value).toLocaleString('sv-SE', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function actorBadge(name: string) { + return name + .split(/[\s_.-]+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() || '') + .join('') || 'SY'; +} + +function inferActorKind(name: string) { + const lowered = name.toLowerCase(); + if (lowered.includes('queue') || lowered.includes('system') || lowered.includes('scheduler')) { + return { actorKind: 'service' as const, actorIcon: 'robot_2' }; + } + + if (lowered.includes('ip:') || lowered.includes('http') || lowered.includes('webhook')) { + return { actorKind: 'network' as const, actorIcon: 'public' }; + } + + return { actorKind: 'user' as const, actorBadge: actorBadge(name) }; +} + +function mapAuditStatus(actionType: string, severity: 'default' | 'alert'): { label: string; tone: StatusTone } { + const lowered = actionType.toLowerCase(); + + if (severity === 'alert' || lowered.includes('fail') || lowered.includes('reject')) { + return { label: 'Rejected', tone: 'error' }; + } + + if (lowered.includes('retry') || lowered.includes('pending') || lowered.includes('queue')) { + return { label: 'Pending', tone: 'warning' }; + } + + if (lowered.includes('approve')) { + return { label: 'Approved', tone: 'success' }; + } + + return { label: 'Success', tone: 'success' }; +} + +function mapPayloadTone(tone: StatusTone): 'primary' | 'success' | 'error' | 'neutral' { + if (tone === 'error') return 'error'; + if (tone === 'warning') return 'neutral'; + return 'primary'; +} + +export default async function LogsPage() { + const token = await requireAuthToken(); + + const [summary, auditTrail] = await Promise.all([ + fetchAnalyticsSummary(token), + fetchAuditTrail(token, { page: 1, limit: 5 }), + ]); + + const queueStats = [ + { label: 'Pending Jobs', value: summary.queue.pendingJobs.toLocaleString('en-US'), tone: 'info', icon: 'schedule' }, + { label: 'Processing', value: summary.queue.processingJobs.toLocaleString('en-US'), tone: 'primary', icon: 'autorenew' }, + { label: 'Failed (24H)', value: summary.queue.failedJobs24h.toLocaleString('en-US'), tone: 'error', icon: 'error_outline' }, + ] as const; + + const liveWorkerHealth = summary.workers.length > 0 ? summary.workers : fallbackWorkerHealth; + + const liveLogs = + auditTrail.items.length > 0 + ? auditTrail.items.map((entry) => { + const status = mapAuditStatus(entry.actionType, entry.severity); + return { + timestamp: formatTimestamp(entry.createdAt), + action: entry.actionType.toUpperCase().replaceAll(' ', '_'), + detail: entry.details, + actor: entry.actorName, + ...inferActorKind(entry.actorName), + status: status.label, + statusTone: status.tone, + payloadTone: mapPayloadTone(status.tone), + }; + }) + : fallbackAnalyticsLogs; + + const metricData = [ + { + label: 'API Latency', + value: `${summary.metrics.apiLatencyMs}ms`, + meta: summary.health.database === 'ok' ? 'Live' : 'Degraded', + metaTone: summary.health.database === 'ok' ? 'success' : 'warning', + icon: 'bar_chart', + chartTone: 'bars', + chartHeights: summary.metrics.apiLatencyBars.map((value) => `${value}%`), + }, + { + label: 'DB Connections', + value: summary.metrics.databaseConnectionsEstimate.toLocaleString('en-US'), + meta: summary.health.database === 'ok' ? 'Active' : 'Down', + metaTone: summary.health.database === 'ok' ? 'warning' : 'error', + icon: 'database', + chartTone: 'progress', + progress: `${summary.metrics.databaseUsagePercent}%`, + }, + { + label: 'Memory Usage', + value: `${summary.metrics.memoryUsageGbEstimate}GB`, + meta: 'backend-estimated load', + metaTone: 'muted', + icon: 'memory', + chartTone: 'memory', + chartHeights: summary.metrics.memoryBars.map((value) => `${value}%`), + }, + ] as const; + + return ( + +
    +
    +
    +

    Operations

    +

    Activity Logs & Queue Monitor

    +

    + Real-time surveillance of system processes, background jobs, and administrative actions. +

    +
    +
    + + +
    +
    + +
    + + +
    +
    +

    Technical Activity Logs

    + Total: {auditTrail.total.toLocaleString('en-US')} events +
    +
    + + + + + + + + + + + + {liveLogs.map((log) => ( + + + + + + + + ))} + +
    TimestampActionUser / ServiceStatusPayload
    {log.timestamp} +
    + {log.action} + {log.detail} +
    +
    +
    + {log.actorKind === 'user' ? ( + {log.actorBadge} + ) : ( + {log.actorIcon} + )} + {log.actor} +
    +
    + {log.status} + + +
    +
    +
    + + Showing {liveLogs.length} of {auditTrail.total.toLocaleString('en-US')} logs + +
    + + +
    +
    +
    +
    + +
    +
    + terminal +
    +
    +

    Live Tail Mode

    +

    + Pulling live audit trail, queue jobs, and webhook activity from the backend. Current snapshot includes{' '} + {summary.totals.totalJobs} jobs and {summary.totals.totalWebhookEvents} webhook events. +

    +
    + +
    + +
    + {metricData.map((metric) => ( +
    +
    + {metric.label} + {metric.icon} +
    +
    + {metric.value} + {metric.meta} +
    + + {metric.chartTone === 'progress' ? ( +
    +
    +
    + ) : ( +
    + {metric.chartHeights.map((height, index) => ( + = 4 && metric.chartTone === 'bars' ? 'is-primary' : ''} + style={{ height }} + /> + ))} +
    + )} +
    + ))} +
    +
    +
    + ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..44df32c --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,195 @@ +import { DashboardShell } from '../../components/dashboard-shell'; +import { dashboardStats } from '../../lib/mock-data'; + +export default async function DashboardPage() { + return ( + +
    +
    +
    + {dashboardStats.map((item) => ( +
    +
    + {item.label.toUpperCase()} + + + {item.label === 'Total Messages' + ? 'chat_bubble' + : item.label === 'Delivered' + ? 'done_all' + : item.label === 'Read Rate' + ? 'visibility' + : 'error'} + + +
    +
    +

    {item.value}

    + + + {item.tone === 'success' ? 'trending_up' : item.tone === 'warning' ? 'trending_down' : 'remove'} + + {item.delta} + +
    +

    + {item.label === 'Total Messages' + ? 'vs last 30 days' + : item.label === 'Delivered' + ? '1,180,800 messages' + : item.label === 'Read Rate' + ? '914,400 read' + : '19,200 errors'} +

    +
    + ))} +
    + +
    +
    +

    Message Volume (Last 7 Days)

    + +
    +
    +
    + {[ + ['MON', '60%'], + ['TUE', '75%'], + ['WED', '65%'], + ['THU', '90%'], + ['FRI', '80%'], + ['SAT', '40%'], + ['SUN', '35%'], + ].map(([label, height]) => ( +
    +
    + {label} +
    + ))} +
    + + + + + + + + + + +
    +
    + +
    +
    +

    Recent Campaigns

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Campaign NameAudienceStatusDeliveryDate
    Summer Flash Sale #445,200 usersSENT99.2%Oct 24, 2023
    Member Newsletter Oct12,800 usersSCHEDULED--Oct 28, 2023
    Product Launch Beta800 usersDRAFT--Nov 02, 2023
    +
    +
    + + +
    +
    + ); +} diff --git a/frontend/src/app/dashboard/roles/page.tsx b/frontend/src/app/dashboard/roles/page.tsx new file mode 100644 index 0000000..bc3705c --- /dev/null +++ b/frontend/src/app/dashboard/roles/page.tsx @@ -0,0 +1,68 @@ +import { DashboardShell } from '../../../components/dashboard-shell'; +import { RolesPermissionsBoard } from '../../../components/roles-permissions-board'; +import { requireAuthToken } from '../../../lib/auth'; +import { fetchAuditTrail, fetchRoles } from '../../../lib/api'; + +function formatRelativeTime(value: string) { + const diff = Date.now() - new Date(value).getTime(); + const minutes = Math.max(1, Math.floor(diff / 60000)); + + if (minutes < 60) { + return `${minutes} min ago`; + } + + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours} hour${hours === 1 ? '' : 's'} ago`; + } + + const days = Math.floor(hours / 24); + return `${days} day${days === 1 ? '' : 's'} ago`; +} + +function iconForAction(actionType: string) { + if (actionType.includes('Created')) return 'add_circle'; + if (actionType.includes('Updated')) return 'person_edit'; + if (actionType.includes('Deleted')) return 'delete'; + return 'history'; +} + +export default async function RolesPage() { + const token = await requireAuthToken(); + const [roles, auditTrail] = await Promise.all([ + fetchRoles(token), + fetchAuditTrail(token, { limit: 3, module: 'Access Control' }), + ]); + + return ( + +
    +
    +

    Access Control

    +

    Roles & Permissions

    +

    Configure access levels and granular permissions for campaigns, analytics, settings, and audit visibility.

    +
    +
    + + ({ + id: role.id, + name: role.name, + badge: role.badge, + tone: role.tone, + summary: role.summary, + usersAssigned: role.usersAssigned, + icon: role.icon, + permissionRows: role.permissions, + }))} + initialAuditHighlights={auditTrail.items.map((item) => ({ + id: item.id, + icon: iconForAction(item.actionType), + title: item.actionType, + description: item.details, + time: formatRelativeTime(item.createdAt), + }))} + /> +
    + ); +} diff --git a/frontend/src/app/dashboard/settings/api/page.tsx b/frontend/src/app/dashboard/settings/api/page.tsx new file mode 100644 index 0000000..350388c --- /dev/null +++ b/frontend/src/app/dashboard/settings/api/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function DeprecatedApiSettingsPage() { + redirect('/dashboard/settings/whatsapp-api'); +} diff --git a/frontend/src/app/dashboard/settings/audit-trail/page.tsx b/frontend/src/app/dashboard/settings/audit-trail/page.tsx new file mode 100644 index 0000000..c67ef73 --- /dev/null +++ b/frontend/src/app/dashboard/settings/audit-trail/page.tsx @@ -0,0 +1,31 @@ +import { AuditTrailBoard } from '../../../../components/audit-trail-board'; +import { DashboardShell } from '../../../../components/dashboard-shell'; +import { requireAuthToken } from '../../../../lib/auth'; +import { fetchAuditTrail } from '../../../../lib/api'; + +export default async function AuditTrailPage() { + const token = await requireAuthToken(); + const auditTrail = await fetchAuditTrail(token, { page: 1, limit: 50 }); + const normalizedEntries = auditTrail.items.map((entry) => ({ + id: entry.id, + timestamp: entry.createdAt, + adminUser: entry.actorName, + actionType: entry.actionType, + module: entry.module, + ipAddress: entry.ipAddress || '-', + severity: entry.severity, + details: entry.details, + })); + + return ( + + + + ); +} diff --git a/frontend/src/app/dashboard/settings/page.tsx b/frontend/src/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..d2d7f1c --- /dev/null +++ b/frontend/src/app/dashboard/settings/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from 'next/navigation'; +import { DashboardShell } from '../../../components/dashboard-shell'; + +export default async function SettingsPage() { + void DashboardShell; + redirect('/dashboard/settings/whatsapp-api'); +} diff --git a/frontend/src/app/dashboard/settings/security/page.tsx b/frontend/src/app/dashboard/settings/security/page.tsx new file mode 100644 index 0000000..ec028be --- /dev/null +++ b/frontend/src/app/dashboard/settings/security/page.tsx @@ -0,0 +1,49 @@ +import { DashboardShell } from '../../../../components/dashboard-shell'; +import { SecuritySessionCard } from '../../../../components/security-session-card'; +import { TwoFactorSettingsCard } from '../../../../components/two-factor-settings-card'; +import { fetchCurrentSession, fetchTwoFactorStatus } from '../../../../lib/api'; +import { requireAuthToken } from '../../../../lib/auth'; + +export default async function SecuritySettingsPage() { + const token = await requireAuthToken(); + const [status, session] = await Promise.all([fetchTwoFactorStatus(token), fetchCurrentSession(token)]); + + return ( + +
    +
    +

    Settings

    +

    Security

    +

    + Hardening auth production, termasuk TOTP, backup recovery codes, dan sesi admin yang lebih aman. +

    +
    +
    + +
    +
    +
    +
    + JWT Auth + Login dan protected routes aktif +
    +
    + Webhook Verification + Verify token, shared secret, dan Meta signature tersedia +
    +
    + Queue Retry + Webhook jobs dapat di-retry dan tercatat di logs +
    +
    +
    + + +
    + +
    + +
    +
    + ); +} diff --git a/frontend/src/app/dashboard/settings/webhook-logs/page.tsx b/frontend/src/app/dashboard/settings/webhook-logs/page.tsx new file mode 100644 index 0000000..a876f46 --- /dev/null +++ b/frontend/src/app/dashboard/settings/webhook-logs/page.tsx @@ -0,0 +1,102 @@ +import Link from 'next/link'; +import { DashboardShell } from '../../../../components/dashboard-shell'; +import { WebhookRetryForm } from '../../../../components/webhook-retry-form'; +import { requireAuthToken } from '../../../../lib/auth'; +import { fetchJobLogs, fetchWebhookLogs } from '../../../../lib/api'; + +type Props = { + searchParams?: Promise<{ + eventId?: string; + }>; +}; + +function formatTime(value: string | null) { + if (!value) { + return 'Pending'; + } + + return new Date(value).toLocaleString('id-ID', { + dateStyle: 'short', + timeStyle: 'short', + }); +} + +export default async function SettingsWebhookLogsPage({ searchParams }: Props) { + const token = await requireAuthToken(); + const params = (await searchParams) || {}; + const [webhookLogs, jobLogs] = await Promise.all([fetchWebhookLogs(token), fetchJobLogs(token)]); + const selectedLog = + webhookLogs.find((log) => log.eventId === params.eventId) || webhookLogs[0] || null; + + return ( + +
    +
    +

    Settings

    +

    Webhook Logs

    +

    + Delivery log webhook sekarang mengambil data backend nyata, termasuk status event dan job queue. +

    +
    +
    + +
    +
    +
    +
    + Provider + Event + Status + Time +
    + {webhookLogs.map((log) => ( + + {log.provider} + {log.eventType} + {log.processingStatus} + {formatTime((log as { createdAt?: string }).createdAt || null)} + + ))} +
    +
    + +
    + {selectedLog ? ( +
    +
    + {selectedLog.eventId} + {selectedLog.eventType} +
    +
    + {selectedLog.processingStatus} + {selectedLog.processingNotes || 'No processing notes'} +
    +
    + {selectedLog.verified ? 'Verified' : 'Unverified'} + + Sender: {selectedLog.senderPhone || '-'} | Recipient: {selectedLog.recipientPhone || '-'} + +
    + +
    {JSON.stringify(selectedLog.payloadJson, null, 2)}
    +
    + ) : ( +

    No webhook logs available.

    + )} +
    +
    + +
    +
    +

    Recent Queue Jobs

    + Redis + DB audit +
    +
    {JSON.stringify(jobLogs.slice(0, 5), null, 2)}
    +
    +
    + ); +} diff --git a/frontend/src/app/dashboard/settings/whatsapp-api/page.tsx b/frontend/src/app/dashboard/settings/whatsapp-api/page.tsx new file mode 100644 index 0000000..3d1fc07 --- /dev/null +++ b/frontend/src/app/dashboard/settings/whatsapp-api/page.tsx @@ -0,0 +1,99 @@ +import { DashboardShell } from '../../../../components/dashboard-shell'; +import { WhatsappSettingsForm } from '../../../../components/whatsapp-settings-form'; +import { requireAuthToken } from '../../../../lib/auth'; +import { fetchWhatsappSettings } from '../../../../lib/api'; + +function maskSecret(enabled: boolean) { + return enabled ? 'Configured' : 'Not configured'; +} + +export default async function WhatsappApiSettingsPage() { + const token = await requireAuthToken(); + const settings = await fetchWhatsappSettings(token); + + return ( + +
    +
    +

    Settings

    +

    WhatsApp API Setting

    +

    + Halaman ini hanya menampilkan parameter yang memang didukung backend saat ini: provider, + webhook URL, verify token, shared secret/app secret status, dan status koneksi. +

    +
    +
    + +
    + + +
    +
    +
    + {settings.isEnabled ? 'Enabled' : 'Disabled'} + Integration runtime switch +
    +
    + {settings.hasSharedSecret ? 'Shared secret ready' : 'Shared secret missing'} + Generic provider verification +
    +
    + {settings.hasAppSecret ? 'Meta signature ready' : 'Meta signature inactive'} + Meta-specific signature validation +
    +
    + {settings.hasAccessToken ? 'Access token ready' : 'Access token missing'} + Required for outbound message send from Conversations +
    +
    + {settings.webhookUrl} + Current webhook destination URL +
    +
    + {settings.subscriptions.length} active subscriptions + + {settings.availableSubscriptions + .filter((item) => settings.subscriptions.includes(item.key)) + .map((item) => item.label) + .join(', ') || 'No subscriptions enabled'} + +
    +
    +
    +
    + +
    +
    +

    Parameter Match Check

    +

    + Sesuai backend: `provider`, `webhookUrl`, `verifyToken`, `sharedSecret`, `appSecret`, + `accessToken`, `phoneNumberId`, `isEnabled`, `subscriptions`, dan test webhook. +

    +

    + Belum ada di backend: sender pool count, latency metrics, dan delivery + success analytics khusus settings page. +

    +
    +
    +

    Secret Visibility Note

    +

    + Backend tidak mengembalikan nilai secret asli. Form edit hanya bisa mengganti secret, + bukan membaca ulang nilainya. +

    +

    + Status `Configured` berarti secret sudah ada di backend, tetapi nilainya tetap disembunyikan. +

    +
    +
    +
    + ); +} diff --git a/frontend/src/app/dashboard/templates/builder/page.tsx b/frontend/src/app/dashboard/templates/builder/page.tsx new file mode 100644 index 0000000..7b1171f --- /dev/null +++ b/frontend/src/app/dashboard/templates/builder/page.tsx @@ -0,0 +1,27 @@ +import { DashboardShell } from '../../../../components/dashboard-shell'; +import { TemplateBuilderForm } from '../../../../components/template-builder-form'; +import { requireAuthToken } from '../../../../lib/auth'; +import { fetchTemplateById } from '../../../../lib/api'; + +type Props = { + searchParams?: Promise<{ + id?: string; + }>; +}; + +export default async function TemplateBuilderPage({ searchParams }: Props) { + const token = await requireAuthToken(); + const resolvedSearchParams = (await searchParams) || {}; + const templateId = resolvedSearchParams.id?.trim(); + const template = templateId ? await fetchTemplateById(token, templateId) : null; + + return ( + + + + ); +} diff --git a/frontend/src/app/dashboard/templates/page.tsx b/frontend/src/app/dashboard/templates/page.tsx new file mode 100644 index 0000000..5e1e2d6 --- /dev/null +++ b/frontend/src/app/dashboard/templates/page.tsx @@ -0,0 +1,193 @@ +import Link from 'next/link'; +import { DashboardShell } from '../../../components/dashboard-shell'; +import { requireAuthToken } from '../../../lib/auth'; +import { fetchTemplates } from '../../../lib/api'; + +function templateStatusClassName(status: string) { + if (status === 'Pending') return 'templates-status-pill is-pending'; + if (status === 'Rejected' || status === 'Archived') return 'templates-status-pill is-rejected'; + return 'templates-status-pill is-approved'; +} + +type Props = { + searchParams?: Promise<{ + search?: string; + category?: string; + status?: string; + language?: string; + }>; +}; + +export default async function TemplatesPage({ searchParams }: Props) { + const token = await requireAuthToken(); + const resolvedSearchParams = (await searchParams) || {}; + const search = resolvedSearchParams.search?.trim() || ''; + const category = resolvedSearchParams.category?.trim() || ''; + const status = resolvedSearchParams.status?.trim() || ''; + const language = resolvedSearchParams.language?.trim() || ''; + const templatesData = await fetchTemplates(token, { + search: search || undefined, + category: category || undefined, + status: status || undefined, + language: language || undefined, + }); + + const categoryOptions = Array.from(new Set(templatesData.items.map((template) => template.category))).sort(); + const statusOptions = Array.from(new Set(templatesData.items.map((template) => template.status))).sort(); + const languageOptions = Array.from(new Set(templatesData.items.map((template) => template.language))).sort(); + + return ( + +
    +
    +
    +

    Message Templates

    +

    + Create and manage your WhatsApp message templates. All templates must be approved by WhatsApp before + sending. +

    +
    + + add_circle + Create New Template + +
    + +
    + + + + + +
    Showing {templatesData.total} Templates
    +
    + +
    + {templatesData.items.filter((template) => !template.compact).map((template) => ( +
    +
    + {template.status} + + edit + +
    +

    {template.name}

    +

    {template.category}

    +
    +

    {template.preview}

    +
    +
    +
    +
    + + {template.status === 'Rejected' ? 'warning' : 'schedule'} + + {template.updatedLabel} +
    + + edit + +
    +
    + ))} + + {templatesData.items + .filter((template) => template.compact) + .map((template) => ( +
    +
    + mail +
    +
    +

    {template.name}

    +
    + {template.category} + +

    {template.preview}

    +
    +
    +
    + Last Modified + {template.updatedLabel} +
    +
    + {template.status} +
    +
    + + visibility + + + edit + +
    +
    + ))} +
    + +
    +
    + contact_support +
    +
    +

    Need help with Template Guidelines?

    +

    + WhatsApp has strict policies on message content. Ensure your templates follow the Business Policy to avoid + rejection and maintain a high quality rating. +

    +
    + + Read Guidelines + +
    +
    +
    + ); +} diff --git a/frontend/src/app/dashboard/users/page.tsx b/frontend/src/app/dashboard/users/page.tsx new file mode 100644 index 0000000..1d18e3c --- /dev/null +++ b/frontend/src/app/dashboard/users/page.tsx @@ -0,0 +1,25 @@ +import { DashboardShell } from '../../../components/dashboard-shell'; +import { UsersManagementBoard } from '../../../components/users-management-board'; +import { requireAuthToken } from '../../../lib/auth'; +import { fetchRoles, fetchUsers } from '../../../lib/api'; + +export default async function UsersPage() { + const token = await requireAuthToken(); + const [users, roles] = await Promise.all([fetchUsers(token, { page: 1, limit: 10 }), fetchRoles(token)]); + + return ( + + ({ + id: role.id, + name: role.name, + }))} + /> + + ); +} diff --git a/frontend/src/app/dashboard/webhooks/logs/page.tsx b/frontend/src/app/dashboard/webhooks/logs/page.tsx new file mode 100644 index 0000000..0967939 --- /dev/null +++ b/frontend/src/app/dashboard/webhooks/logs/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function DeprecatedWebhookLogsPage() { + redirect('/dashboard/settings/webhook-logs'); +} diff --git a/frontend/src/app/dashboard/webhooks/page.tsx b/frontend/src/app/dashboard/webhooks/page.tsx new file mode 100644 index 0000000..e3aa908 --- /dev/null +++ b/frontend/src/app/dashboard/webhooks/page.tsx @@ -0,0 +1,54 @@ +import { DashboardShell } from '../../../components/dashboard-shell'; +import { webhookLogs } from '../../../lib/mock-data'; + +export default async function WebhooksPage() { + return ( + +
    +
    +

    Webhooks

    +

    Webhook Logs

    +

    Inbound event visibility, payload sampling, and retry monitoring.

    +
    +
    + +
    +
    +
    +
    + Provider + Event + Status + Time +
    + {webhookLogs.map((log) => ( +
    + {log.provider} + {log.event} + {log.status} + {log.time} +
    + ))} +
    +
    + +
    +
    +

    Payload Preview

    + evt_1003 +
    +
    {`{
    +  "provider": "qontak",
    +  "event_type": "message.failed",
    +  "external_message_id": "wamid.xxx",
    +  "timestamp": "2026-05-09T09:28:00.000Z",
    +  "payload": {
    +    "error_code": "THROTTLED",
    +    "retry_count": 2
    +  }
    +}`}
    +
    +
    +
    + ); +} diff --git a/frontend/src/app/forgot-password/page.tsx b/frontend/src/app/forgot-password/page.tsx new file mode 100644 index 0000000..1ab85fe --- /dev/null +++ b/frontend/src/app/forgot-password/page.tsx @@ -0,0 +1,23 @@ +import { LanguageSwitcher } from '../../components/language-switcher'; +import { ForgotPasswordCard } from '../../components/forgot-password-card'; +import { getDictionary, getLocale } from '../../lib/i18n'; + +export default async function ForgotPasswordPage() { + const locale = await getLocale(); + const dict = await getDictionary(); + + return ( + + } + /> + ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..b492a6a --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,9138 @@ +@import url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@400;500;600&family=JetBrains+Mono&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"); + +:root { + color-scheme: light; + --bg: #f8f9fa; + --bg-accent: radial-gradient(circle at top left, rgba(37, 211, 102, 0.12), transparent 28%), radial-gradient(circle at bottom right, rgba(0, 109, 47, 0.08), transparent 24%), linear-gradient(180deg, #f3fcef 0%, #f8f9fa 100%); + --surface: rgba(255, 255, 255, 0.9); + --surface-strong: #ffffff; + --border: #bbcbb9; + --text: #151e16; + --muted: #3c4a3d; + --muted-soft: #64748b; + --accent: #006d2f; + --accent-dark: #005523; + --accent-bright: #25d366; + --accent-surface: #e7f1e4; + --danger: #b42318; + --success: #15703c; + --shadow: 0 18px 60px rgba(21, 30, 22, 0.08); + --shadow-soft: 0 4px 20px rgba(0, 0, 0, 0.05); +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + background: var(--bg); + background-image: var(--bg-accent); + color: var(--text); +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +body { + min-height: 100vh; +} + +.marketing-page, +.auth-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px; +} + +.hero-card, +.auth-card, +.card { + background: var(--surface); + border: 1px solid var(--border); + backdrop-filter: blur(14px); + box-shadow: var(--shadow); +} + +.hero-card { + max-width: 780px; + border-radius: 28px; + padding: 40px; +} + +.hero-card h1, +.auth-card h1, +.page-title { + margin: 0; + font-size: clamp(2rem, 5vw, 3.4rem); + line-height: 1.04; +} + +.hero-card p, +.auth-copy, +.section-copy { + color: var(--muted); + line-height: 1.7; +} + +.hero-actions { + display: flex; + gap: 12px; + margin-top: 24px; +} + +.primary-button, +button { + border: 0; + border-radius: 14px; + padding: 12px 18px; + background: var(--accent); + color: white; + font-weight: 600; +} + +.secondary-button { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} + +.eyebrow { + margin: 0 0 10px; + color: var(--accent-dark); + font-size: 0.82rem; + letter-spacing: 0.12em; + text-transform: uppercase; + font-weight: 700; +} + +.auth-card { + width: min(460px, 100%); + border-radius: 24px; + padding: 32px; +} + +.auth-link { + margin: 18px 0 0; + color: var(--muted); +} + +.stack-form { + display: grid; + gap: 14px; +} + +.stack-form label { + display: grid; + gap: 8px; + font-size: 0.95rem; + color: var(--muted); +} + +.stack-form input, +.stack-form textarea, +.language-switcher select { + width: 100%; + border-radius: 14px; + border: 1px solid var(--border); + background: var(--surface-strong); + padding: 12px 14px; + color: var(--text); +} + +.page-shell { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 290px; + padding: 28px 20px; + border-right: 1px solid var(--border); + background: rgba(250, 252, 251, 0.84); + backdrop-filter: blur(18px); + display: flex; + flex-direction: column; + gap: 24px; +} + +.sidebar-top { + display: grid; + gap: 16px; +} + +.sidebar-top h2 { + margin: 0; + font-size: 1.4rem; +} + +.sidebar-nav { + display: grid; + gap: 10px; +} + +.sidebar-nav a, +.sidebar-muted { + padding: 12px 14px; + border-radius: 14px; + color: var(--text); +} + +.sidebar-nav a:hover { + background: rgba(31, 157, 85, 0.08); +} + +.sidebar-muted { + color: #8a958f; +} + +.sidebar-logout { + width: 100%; + margin-top: auto; +} + +.content { + flex: 1; + padding: 32px; +} + +.section-head { + margin-bottom: 24px; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.card { + border-radius: 22px; + padding: 22px; +} + +.metric-label { + color: var(--muted); + margin-bottom: 10px; +} + +.metric-value { + font-size: 2rem; + font-weight: 700; +} + +.list-card ul { + margin: 0; + padding-left: 18px; +} + +.list-card li { + padding: 8px 0; +} + +.language-switcher { + display: grid; + gap: 8px; +} + +.language-switcher-compact { + display: inline-flex; + gap: 4px; + padding: 4px; + border-radius: 999px; + border: 1px solid rgba(187, 203, 185, 0.7); + background: rgba(231, 241, 228, 0.88); +} + +.language-switcher-compact button { + min-width: 44px; + padding: 8px 12px; + border-radius: 999px; + background: transparent; + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 700; + box-shadow: none; +} + +.language-switcher-compact button.is-active { + background: white; + color: var(--accent); + box-shadow: 0 1px 4px rgba(21, 30, 22, 0.08); +} + +.language-switcher label { + display: grid; + gap: 8px; + color: var(--muted); + font-size: 0.9rem; +} + +.form-error { + color: var(--danger); + margin: 0; +} + +.form-success { + color: var(--success); + margin: 0; +} + +.auth-page-enterprise { + position: relative; + overflow: hidden; +} + +.auth-page-login { + padding: 40px 24px 32px; +} + +.auth-enterprise-glow { + position: fixed; + z-index: 0; + width: 360px; + height: 360px; + border-radius: 999px; + background: radial-gradient(circle, rgba(37, 211, 102, 0.15), transparent 68%); + pointer-events: none; +} + +.auth-enterprise-glow-right { + top: -90px; + right: -80px; +} + +.auth-enterprise-glow-left { + bottom: -120px; + left: -120px; +} + +.auth-container, +.two-factor-shell { + width: min(100%, 440px); + display: grid; + gap: 32px; + position: relative; + z-index: 1; +} + +.two-factor-shell { + width: min(100%, 480px); +} + +.auth-container-login { + gap: 24px; +} + +.auth-login-toolbar { + display: flex; + justify-content: center; +} + +.auth-login-locale { + display: inline-flex; + justify-content: center; +} + +.auth-brand, +.two-factor-brand { + display: grid; + justify-items: center; + text-align: center; + gap: 16px; +} + +.auth-brand-mark, +.two-factor-mark { + width: 64px; + height: 64px; + border-radius: 18px; + background: var(--accent-bright); + color: white; + display: grid; + place-items: center; + box-shadow: 0 6px 16px rgba(0, 109, 47, 0.18); +} + +.auth-brand-mark svg, +.two-factor-mark svg, +.button-icon svg, +.input-icon svg, +.security-icon svg { + width: 24px; + height: 24px; +} + +.auth-brand-mark svg { + width: 38px; + height: 38px; +} + +.two-factor-mark svg { + width: 34px; + height: 34px; +} + +.auth-brand-copy, +.two-factor-brand h1 { + display: grid; + gap: 6px; +} + +.auth-brand-copy { + justify-items: center; +} + +.auth-brand-copy h1, +.two-factor-brand h1 { + margin: 0; + font-size: 2rem; + line-height: 1.3; + font-weight: 800; + letter-spacing: -0.03em; +} + +.auth-brand-copy h2 { + margin: 0; + font-size: 1.15rem; + line-height: 1.3; + font-weight: 700; + color: var(--text); +} + +.auth-brand-copy p, +.two-factor-brand p { + margin: 0; + color: var(--muted-soft); + max-width: 28rem; +} + +.auth-card-enterprise, +.two-factor-card { + background: var(--surface-strong); + border-radius: 22px; + box-shadow: var(--shadow-soft); + border: 1px solid rgba(187, 203, 185, 0.3); + padding: 40px 46px; +} + +.enterprise-form { + display: grid; + gap: 24px; +} + +.auth-form-block { + display: grid; + gap: 10px; +} + +.field-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.field-label { + font-size: 0.76rem; + line-height: 1rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 700; + color: var(--muted); +} + +.text-link { + border: 0; + background: transparent; + padding: 0; + color: var(--accent); + font-size: 0.9rem; + font-weight: 600; +} + +.text-link:hover { + text-decoration: underline; +} + +.text-link-compact { + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-50%); + padding-right: 14px; + z-index: 1; +} + +.input-shell { + position: relative; + display: flex; + align-items: center; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface-strong); + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +.input-shell:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 4px rgba(37, 211, 102, 0.12); +} + +.input-shell input { + flex: 1 1 auto; + width: 100%; + min-width: 0; + border: 0; + background: transparent; + border-radius: 14px; + padding: 14px 92px 14px 46px; + color: var(--text); +} + +.input-shell input::placeholder { + color: #8ea08c; +} + +.input-icon { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + color: #8ba08c; + pointer-events: none; +} + +.check-row { + display: flex; + align-items: center; + gap: 12px; + color: var(--muted); + font-size: 0.95rem; +} + +.check-row input { + width: 18px; + height: 18px; + accent-color: var(--accent); +} + +.auth-submit { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 14px 18px; + border-radius: 14px; + background: var(--accent-bright); + color: white; + box-shadow: 0 8px 16px rgba(37, 211, 102, 0.24); +} + +.auth-submit:hover { + filter: brightness(0.98); +} + +.auth-submit[disabled], +.auth-secondary-button[disabled] { + opacity: 0.9; +} + +.button-icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.brand-google svg { + width: 18px; + height: 18px; +} + +.auth-divider { + position: relative; + text-align: center; + margin: 20px 0 0; +} + +.auth-divider::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 1px; + background: var(--border); +} + +.auth-divider span { + position: relative; + z-index: 1; + background: var(--surface-strong); + padding: 0 14px; + font-size: 0.76rem; + line-height: 1rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6c7b6b; + font-weight: 700; +} + +.auth-secondary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.auth-secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 14px; + border-radius: 14px; + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} + +.auth-secondary-button:hover { + background: rgba(237, 246, 233, 0.7); +} + +.auth-assist-row { + margin-top: 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + color: var(--muted-soft); + font-size: 0.9rem; +} + +.auth-assist-row p { + margin: 0; +} + +.auth-footer { + display: grid; + justify-items: center; + gap: 16px; + text-align: center; +} + +.auth-footer p { + margin: 0; + color: var(--muted-soft); +} + +.auth-footer .text-link { + display: inline; +} + +.auth-footer-links { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 18px; +} + +.auth-footer-links button { + border: 0; + background: transparent; + padding: 0; + color: #6c7b6b; + font-size: 0.76rem; + line-height: 1rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 700; +} + +.auth-footer-links button:hover { + color: var(--muted); +} + +.auth-page-symbol { + position: fixed; + z-index: 0; + display: none; + opacity: 0.08; + color: var(--accent); + pointer-events: none; +} + +.auth-page-symbol .material-symbols-outlined { + font-size: 320px; + line-height: 1; +} + +.auth-page-symbol-top { + top: 0; + right: 0; + padding: 48px; +} + +.auth-page-symbol-bottom { + bottom: 0; + left: 0; + padding: 48px; +} + +.auth-page-login .language-switcher-compact { + background: rgba(231, 241, 228, 0.95); + border-color: rgba(187, 203, 185, 0.9); + box-shadow: 0 12px 32px rgba(21, 30, 22, 0.06); +} + +.auth-page-login .language-switcher-compact button { + min-width: 50px; + padding: 9px 14px; + font-size: 0.9rem; + letter-spacing: 0.03em; +} + +.auth-page-login .language-switcher-compact button.is-active { + color: var(--accent); +} + +.two-factor-copy { + display: grid; + gap: 12px; + text-align: center; + margin-bottom: 28px; +} + +.two-factor-copy h2 { + margin: 0; + font-size: 1.25rem; +} + +.two-factor-copy p, +.security-info-card p, +.placeholder-note p { + margin: 0; + color: var(--muted); + line-height: 1.7; +} + +.otp-row { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 24px; +} + +.otp-box { + height: 58px; + display: grid; + place-items: center; + border-radius: 14px; + border: 1px solid var(--border); + background: white; + color: var(--accent-dark); + font-size: 1.3rem; + font-weight: 700; +} + +.two-factor-meta { + margin-top: 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.placeholder-note { + margin-top: 22px; + border-radius: 16px; + background: var(--accent-surface); + padding: 16px 18px; +} + +.placeholder-note strong { + display: block; + margin-bottom: 6px; + color: var(--accent-dark); +} + +.two-factor-info-grid { + display: grid; + gap: 18px; +} + +.security-info-card { + display: flex; + align-items: flex-start; + gap: 14px; + background: rgba(231, 241, 228, 0.72); + border: 1px solid rgba(187, 203, 185, 0.2); + border-radius: 20px; + padding: 22px; +} + +.security-info-card h3 { + margin: 0 0 6px; + font-size: 0.82rem; + line-height: 1rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.security-icon { + width: 40px; + height: 40px; + flex: 0 0 auto; + border-radius: 12px; + background: white; + display: grid; + place-items: center; + color: var(--accent); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06); +} + +.dashboard-app { + min-height: 100vh; + display: grid; + grid-template-columns: 260px minmax(0, 1fr); +} + +.dashboard-sidebar { + position: sticky; + top: 0; + height: 100vh; + padding: 24px 16px; + background: rgba(255, 255, 255, 0.88); + border-right: 1px solid rgba(187, 203, 185, 0.45); + backdrop-filter: blur(18px); + display: flex; + flex-direction: column; + gap: 18px; +} + +.dashboard-brand h1 { + margin: 0; + font-size: 1.55rem; + color: var(--accent); +} + +.dashboard-brand p { + margin: 6px 0 0; + color: var(--muted-soft); + font-size: 0.92rem; +} + +.dashboard-primary-action { + display: inline-flex; + justify-content: center; + align-items: center; + padding: 13px 16px; + border-radius: 16px; + background: var(--accent-bright); + color: white; + font-weight: 700; + box-shadow: 0 10px 18px rgba(37, 211, 102, 0.2); +} + +.dashboard-nav { + display: grid; + gap: 6px; +} + +.dashboard-nav-link, +.dashboard-logout-button { + width: 100%; + text-align: left; + padding: 12px 14px; + border-radius: 14px; + color: var(--muted); + background: transparent; + border: 0; + font-weight: 600; +} + +.dashboard-nav-link:hover, +.dashboard-logout-button:hover { + background: rgba(231, 241, 228, 0.95); +} + +.dashboard-nav-link.is-active { + background: rgba(37, 211, 102, 0.18); + color: var(--accent-dark); +} + +.dashboard-sidebar-footer { + margin-top: auto; + padding-top: 16px; + border-top: 1px solid rgba(187, 203, 185, 0.45); + display: grid; + gap: 6px; +} + +.dashboard-main { + min-width: 0; +} + +.dashboard-topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + padding: 18px 32px; + background: rgba(243, 252, 239, 0.78); + border-bottom: 1px solid rgba(187, 203, 185, 0.35); + backdrop-filter: blur(14px); +} + +.dashboard-topbar-left { + display: flex; + align-items: center; + gap: 18px; +} + +.dashboard-topbar-title { + display: grid; + gap: 2px; +} + +.dashboard-topbar-title p { + margin: 0; + color: var(--accent); + font-size: 0.8rem; + font-weight: 700; +} + +.dashboard-topbar-title h2 { + margin: 0; + font-size: 1.2rem; +} + +.dashboard-search { + flex: 1; + max-width: 420px; +} + +.dashboard-search input, +.form-stack input, +.form-stack textarea, +.chat-composer input { + width: 100%; + border: 1px solid rgba(187, 203, 185, 0.65); + border-radius: 14px; + padding: 13px 15px; + background: rgba(255, 255, 255, 0.92); + color: var(--text); +} + +.dashboard-topbar-actions { + display: flex; + align-items: center; + gap: 16px; +} + +.dashboard-icon-button { + width: 42px; + height: 42px; + padding: 0; + border-radius: 999px; + background: transparent; + color: var(--muted); + border: 1px solid rgba(187, 203, 185, 0.5); + box-shadow: none; + display: grid; + place-items: center; +} + +.dashboard-icon-button span { + width: 16px; + height: 16px; + border-radius: 999px; + border: 2px solid currentColor; + display: block; +} + +.dashboard-profile { + display: flex; + align-items: center; + gap: 12px; +} + +.dashboard-profile strong, +.page-heading, +.surface-card h2, +.surface-card h3 { + margin: 0; +} + +.dashboard-profile span { + display: block; + color: var(--muted-soft); + font-size: 0.8rem; +} + +.dashboard-avatar { + width: 42px; + height: 42px; + border-radius: 999px; + background: linear-gradient(135deg, var(--accent), var(--accent-bright)); + color: white; + display: grid; + place-items: center; + font-weight: 700; +} + +.dashboard-content { + padding: 32px; + display: grid; + gap: 24px; +} + +.dashboard-main-conversations { + height: 100dvh; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; +} + +.dashboard-content-conversations { + height: 100%; + min-height: 0; + overflow: hidden; +} + +.overview-grid { + display: grid; + grid-template-columns: minmax(0, 1.6fr) 320px; + gap: 24px; +} + +.overview-main, +.overview-side { + display: grid; + gap: 24px; + align-content: start; +} + +.overview-kpi-card { + min-height: 164px; +} + +.overview-kpi-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.overview-kpi-dot { + width: 14px; + height: 14px; + border-radius: 999px; + background: rgba(220, 229, 216, 1); +} + +.overview-kpi-dot.success { + background: rgba(37, 211, 102, 1); +} + +.overview-kpi-dot.warning { + background: rgba(245, 158, 11, 1); +} + +.overview-volume-card { + min-height: 428px; +} + +.overview-campaign-grid { + grid-template-columns: minmax(0, 1.6fr) 0.8fr 0.8fr; +} + +.page-section, +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 18px; +} + +.page-header.split { + align-items: center; +} + +.page-eyebrow { + margin: 0 0 10px; + color: var(--accent-dark); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 700; +} + +.page-heading { + font-size: clamp(2rem, 4vw, 2.7rem); + line-height: 1.06; +} + +.page-copy, +.card-caption, +.surface-card p, +.surface-card span, +.surface-card small { + color: var(--muted-soft); +} + +.header-actions, +.button-row, +.tabs-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +.surface-card { + background: rgba(255, 255, 255, 0.94); + border-radius: 24px; + border: 1px solid rgba(187, 203, 185, 0.35); + box-shadow: var(--shadow-soft); + padding: 22px; +} + +.surface-link-card { + display: block; +} + +.surface-link-card:hover { + transform: translateY(-1px); +} + +.dashboard-stat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; +} + +.card-kicker { + margin: 0 0 12px; + color: var(--muted); + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 700; +} + +.card-metric-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.card-metric-row h3, +.surface-card > h3 { + font-size: 2rem; + line-height: 1; +} + +.status-pill, +.soft-chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 700; +} + +.soft-chip { + background: rgba(231, 241, 228, 1); + color: var(--accent-dark); +} + +.status-pill.success { + background: rgba(37, 211, 102, 0.18); + color: var(--accent-dark); +} + +.status-pill.warning { + background: rgba(245, 158, 11, 0.18); + color: #9a5a00; +} + +.status-pill.neutral { + background: rgba(220, 229, 216, 1); + color: var(--muted); +} + +.dashboard-two-column { + display: grid; + grid-template-columns: 1.65fr 1fr; + gap: 16px; +} + +.dashboard-two-column-bottom { + grid-template-columns: 1.3fr 1fr; +} + +.tall-card { + min-height: 360px; +} + +.card-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 18px; +} + +.chart-bars { + height: 280px; + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 14px; + align-items: end; +} + +.chart-column { + display: grid; + justify-items: center; + gap: 10px; + height: 100%; +} + +.chart-column div { + width: 100%; + border-radius: 18px 18px 10px 10px; + background: linear-gradient(180deg, rgba(37, 211, 102, 0.95), rgba(0, 109, 47, 0.88)); +} + +.metric-stack { + display: grid; + gap: 18px; +} + +.metric-stack div { + display: grid; + gap: 6px; + padding: 16px; + border-radius: 18px; + background: rgba(243, 252, 239, 0.88); +} + +.metric-stack strong, +.detail-stack strong { + color: var(--text); + font-size: 1.18rem; +} + +.detail-stack { + display: grid; + gap: 16px; +} + +.detail-stack div { + display: grid; + gap: 6px; +} + +.plain-list { + margin: 0; + padding-left: 18px; + display: grid; + gap: 12px; +} + +.filter-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +.data-table { + display: grid; +} + +.data-row { + display: grid; + align-items: center; + gap: 16px; + padding: 16px 0; + border-bottom: 1px solid rgba(187, 203, 185, 0.3); +} + +.data-row:last-child { + border-bottom: 0; +} + +.data-row-head { + padding-top: 0; + color: var(--muted); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 700; +} + +.data-row-link:hover { + background: rgba(243, 252, 239, 0.65); +} + +.contacts-grid, +.campaign-grid, +.message-grid, +.webhook-grid, +.users-grid, +.logs-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.conversation-layout { + display: grid; + grid-template-columns: 340px minmax(0, 1fr); + gap: 16px; +} + +.conversation-list { + display: grid; + gap: 10px; +} + +.conversation-item { + border-radius: 18px; + padding: 16px; + background: rgba(243, 252, 239, 0.72); + border: 1px solid transparent; +} + +.conversation-item.active { + border-color: rgba(37, 211, 102, 0.32); + background: rgba(231, 241, 228, 0.94); +} + +.conversation-item p, +.conversation-item span { + margin: 6px 0 0; +} + +.conversation-chat { + display: grid; + gap: 18px; +} + +.chat-header, +.chat-composer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.chat-thread { + min-height: 340px; + display: grid; + align-content: start; + gap: 12px; + padding: 12px 0; +} + +.chat-bubble { + max-width: 70%; + padding: 14px 16px; + border-radius: 20px; + line-height: 1.6; +} + +.chat-bubble.incoming { + background: rgba(231, 241, 228, 1); +} + +.chat-bubble.outgoing { + margin-left: auto; + background: rgba(37, 211, 102, 0.18); +} + +.tabs-row { + margin-bottom: 18px; + color: var(--muted-soft); +} + +.tab-active { + color: var(--accent); + font-weight: 700; + border-bottom: 2px solid var(--accent); + padding-bottom: 8px; +} + +.form-stack { + display: grid; + gap: 14px; +} + +.form-stack label { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 700; +} + +.form-stack input, +.form-stack select, +.form-stack textarea { + width: 100%; + border: 1px solid rgba(187, 203, 185, 0.55); + background: rgba(243, 252, 239, 0.8); + color: var(--text); + border-radius: 14px; + padding: 12px 14px; +} + +.settings-form-stack { + display: grid; + gap: 16px; +} + +.settings-checkbox { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: var(--text); +} + +.settings-checkbox span:first-of-type { + display: grid; + gap: 4px; +} + +.settings-checkbox strong { + color: var(--text); +} + +.settings-checkbox small { + color: var(--muted-soft); +} + +.settings-subscriptions { + display: grid; + gap: 10px; + padding-top: 6px; +} + +.settings-subscriptions p { + margin: 0; + color: var(--muted-soft); + font-size: 0.92rem; +} + +.settings-subscription-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border-radius: 16px; + background: rgba(243, 252, 239, 0.72); + border: 1px solid rgba(187, 203, 185, 0.35); +} + +.settings-subscription-row span { + display: grid; + gap: 4px; +} + +.settings-subscription-row strong { + color: var(--text); +} + +.settings-subscription-row small { + color: var(--muted-soft); +} + +.settings-toggle { + position: relative; + display: inline-flex; + align-items: center; + flex: 0 0 auto; +} + +.settings-toggle input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; + margin: 0; +} + +.settings-toggle-ui { + position: relative; + width: 48px; + height: 28px; + border-radius: 999px; + background: rgba(187, 203, 185, 0.9); + border: 1px solid rgba(108, 123, 107, 0.35); + transition: background-color 160ms ease, border-color 160ms ease; +} + +.settings-toggle-ui::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + border-radius: 999px; + background: white; + box-shadow: 0 2px 6px rgba(21, 30, 22, 0.18); + transition: transform 160ms ease; +} + +.settings-toggle input:checked + .settings-toggle-ui { + background: var(--accent); + border-color: var(--accent); +} + +.settings-toggle input:checked + .settings-toggle-ui::after { + transform: translateX(20px); +} + +.retry-form { + display: grid; + gap: 10px; +} + +.phone-preview { + display: grid; + place-items: center; +} + +.phone-screen { + width: 260px; + min-height: 460px; + border-radius: 36px; + background: linear-gradient(180deg, rgba(243, 252, 239, 1), rgba(231, 241, 228, 1)); + padding: 22px; + display: grid; + align-content: start; +} + +.phone-bubble { + padding: 14px 16px; + border-radius: 18px 18px 18px 6px; + background: white; + line-height: 1.6; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.06); +} + +.creative-preview { + display: grid; + gap: 14px; +} + +.creative-image { + height: 180px; + border-radius: 20px; + background: linear-gradient(135deg, rgba(37, 211, 102, 0.24), rgba(255, 160, 126, 0.34)); +} + +.template-grid, +.permission-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} + +.roles-create-button, +.role-action-button, +.roles-help-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + border-radius: 16px; + padding: 12px 18px; + font-weight: 700; +} + +.roles-create-button { + background: var(--accent); + color: white; + box-shadow: 0 14px 24px rgba(0, 109, 47, 0.18); +} + +.roles-card-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.roles-create-panel { + background: rgba(255, 255, 255, 0.96); + border: 1px solid rgba(187, 203, 185, 0.65); + border-radius: 24px; + padding: 28px; + box-shadow: var(--shadow-soft); + display: grid; + gap: 22px; +} + +.roles-create-panel-head h3 { + margin: 6px 0 0; + font-size: 1.3rem; +} + +.roles-create-copy { + margin: 10px 0 0; + max-width: 760px; + color: var(--muted-soft); + line-height: 1.65; +} + +.roles-create-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} + +.roles-create-field { + display: grid; + align-content: start; + gap: 10px; + color: var(--muted); + font-weight: 600; +} + +.roles-create-field span { + color: var(--muted); + font-size: 0.98rem; + font-weight: 700; +} + +.roles-create-field input, +.roles-create-field textarea { + width: 100%; + border: 1px solid rgba(108, 123, 107, 0.45); + border-radius: 18px; + background: rgba(255, 255, 255, 0.98); + color: var(--text); + padding: 16px 18px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); + transition: border-color 150ms ease, box-shadow 150ms ease, background 150ms ease; +} + +.roles-create-field input { + min-height: 60px; + font-size: 1.02rem; + font-weight: 600; +} + +.roles-create-form textarea { + resize: vertical; + min-height: 132px; + line-height: 1.6; +} + +.roles-create-field input:focus, +.roles-create-field textarea:focus { + outline: none; + border-color: rgba(0, 109, 47, 0.72); + box-shadow: 0 0 0 4px rgba(37, 211, 102, 0.14); + background: #fff; +} + +.roles-create-field small { + color: var(--muted-soft); + font-size: 0.82rem; + line-height: 1.55; + font-weight: 500; +} + +.roles-create-actions { + padding-top: 2px; +} + +.role-card { + text-align: left; + background: rgba(255, 255, 255, 0.96); + border: 1px solid rgba(187, 203, 185, 0.65); + border-radius: 24px; + padding: 22px; + box-shadow: var(--shadow-soft); + transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease; + cursor: pointer; +} + +.role-card:hover { + transform: translateY(-1px); + box-shadow: 0 14px 36px rgba(21, 30, 22, 0.08); +} + +.role-card.is-selected { + border-color: rgba(0, 109, 47, 0.6); + box-shadow: 0 0 0 2px rgba(37, 211, 102, 0.22), 0 18px 40px rgba(21, 30, 22, 0.1); +} + +.role-card-head, +.permission-matrix-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.role-icon { + width: 52px; + height: 52px; + border-radius: 18px; + display: grid; + place-items: center; +} + +.role-icon .material-symbols-outlined { + font-size: 26px; +} + +.role-icon.tone-primary { + background: rgba(37, 211, 102, 0.16); + color: var(--accent); +} + +.role-icon.tone-secondary { + background: rgba(140, 241, 225, 0.22); + color: #006b5f; +} + +.role-icon.tone-tertiary { + background: rgba(255, 160, 126, 0.18); + color: #93492e; +} + +.role-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 30px; + padding: 6px 12px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.role-badge.tone-primary { + background: var(--accent); + color: white; +} + +.role-badge.tone-secondary { + background: rgba(220, 229, 216, 0.9); + color: var(--muted); +} + +.role-badge.tone-tertiary { + background: rgba(37, 211, 102, 0.18); + color: var(--accent-dark); +} + +.role-badge.tone-editing { + background: rgba(37, 211, 102, 0.16); + color: var(--accent-dark); +} + +.role-card h2, +.roles-help-copy h3, +.roles-section-title { + margin: 18px 0 8px; + color: var(--text); + font-size: 1.42rem; + line-height: 1.1; +} + +.role-card p, +.roles-audit-copy p, +.roles-help-copy p { + margin: 0; + color: var(--muted-soft); + line-height: 1.65; +} + +.role-card-meta { + display: inline-flex; + align-items: center; + gap: 8px; + margin-top: 18px; + color: var(--muted); + font-size: 0.92rem; + font-weight: 600; +} + +.role-card-actions { + margin-top: 18px; + display: flex; + justify-content: flex-end; +} + +.role-inline-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(187, 203, 185, 0.65); + background: rgba(243, 252, 239, 0.92); + color: var(--accent-dark); + font-weight: 700; +} + +.role-inline-button.is-active { + background: rgba(37, 211, 102, 0.16); + border-color: rgba(0, 109, 47, 0.3); +} + +.permission-matrix-card { + background: rgba(255, 255, 255, 0.96); + border: 1px solid rgba(187, 203, 185, 0.65); + border-radius: 24px; + box-shadow: var(--shadow-soft); + overflow: hidden; +} + +.permission-matrix-head { + padding: 22px 24px; + border-bottom: 1px solid rgba(187, 203, 185, 0.45); +} + +.permission-matrix-head h3 { + margin: 6px 0 0; + font-size: 1.35rem; +} + +.permission-matrix-head h3 span { + color: var(--accent); +} + +.permission-matrix-copy { + margin: 10px 0 0; + color: var(--muted-soft); + max-width: 720px; + line-height: 1.65; +} + +.roles-feedback { + margin: 0; + padding: 0 24px 18px; + color: var(--accent-dark); + font-weight: 600; +} + +.permission-table-wrap { + overflow-x: auto; +} + +.permission-table { + width: 100%; + border-collapse: collapse; +} + +.permission-table th, +.permission-table td { + padding: 18px 24px; + border-bottom: 1px solid rgba(187, 203, 185, 0.35); +} + +.permission-table th { + background: rgba(237, 246, 233, 0.92); + color: var(--muted); + font-size: 0.76rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + text-align: center; +} + +.permission-table th:first-child, +.permission-table td:first-child { + text-align: left; +} + +.permission-table tbody tr:last-child td { + border-bottom: 0; +} + +.permission-label { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.permission-label .material-symbols-outlined { + color: var(--muted); + font-size: 22px; + margin-top: 1px; +} + +.permission-label div { + display: grid; + gap: 4px; +} + +.permission-label strong { + color: var(--text); + font-size: 0.98rem; +} + +.permission-label small { + color: var(--muted-soft); + font-size: 0.84rem; +} + +.permission-table td:not(:first-child) { + text-align: center; +} + +.permission-unavailable { + color: var(--muted-soft); + font-weight: 700; +} + +.permission-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 64px; + padding: 7px 10px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.permission-indicator.is-on { + background: rgba(37, 211, 102, 0.18); + color: var(--accent-dark); +} + +.permission-indicator.is-off { + background: rgba(220, 229, 216, 0.95); + color: var(--muted); +} + +.toggle-switch { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.toggle-switch input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.toggle-track { + width: 46px; + height: 26px; + border-radius: 999px; + background: rgba(220, 229, 216, 1); + padding: 4px; + display: inline-flex; + align-items: center; + transition: background 150ms ease; +} + +.toggle-thumb { + width: 18px; + height: 18px; + border-radius: 999px; + background: white; + box-shadow: 0 2px 6px rgba(21, 30, 22, 0.16); + transition: transform 150ms ease; +} + +.toggle-switch input:checked + .toggle-track { + background: rgba(37, 211, 102, 1); +} + +.toggle-switch input:checked + .toggle-track .toggle-thumb { + transform: translateX(20px); +} + +.roles-bottom-grid { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.8fr); + gap: 18px; +} + +.roles-inline-link { + color: var(--accent); + font-weight: 700; +} + +.roles-audit-list { + display: grid; +} + +.roles-audit-item { + display: grid; + grid-template-columns: 52px minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + padding: 18px 0; + border-bottom: 1px solid rgba(187, 203, 185, 0.35); +} + +.roles-audit-item:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.roles-audit-icon { + width: 44px; + height: 44px; + border-radius: 16px; + background: rgba(231, 241, 228, 0.92); + display: grid; + place-items: center; + color: var(--accent); +} + +.roles-audit-copy { + display: grid; + gap: 4px; +} + +.roles-audit-copy strong { + color: var(--text); +} + +.roles-audit-time { + color: var(--muted-soft); + font-size: 0.76rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.roles-help-card { + position: relative; + overflow: hidden; + border-radius: 24px; + padding: 24px; + background: linear-gradient(180deg, rgba(0, 109, 47, 0.96), rgba(0, 83, 34, 0.98)); + color: white; + box-shadow: 0 18px 42px rgba(0, 109, 47, 0.2); +} + +.roles-help-copy { + position: relative; + z-index: 1; + display: grid; + gap: 12px; +} + +.roles-help-copy .card-kicker, +.roles-help-copy p { + color: rgba(255, 255, 255, 0.86); +} + +.roles-help-copy h3 { + color: white; +} + +.roles-help-button { + justify-self: start; + background: white; + color: var(--accent); +} + +.roles-help-mark { + position: absolute; + right: -10px; + bottom: -6px; + font-size: 128px; + opacity: 0.12; +} + +.audit-export-button { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-radius: 14px; + border: 1px solid rgba(187, 203, 185, 0.65); + background: rgba(255, 255, 255, 0.94); + color: var(--text); + font-weight: 700; +} + +.audit-export-button.secondary { + background: rgba(243, 252, 239, 0.92); +} + +.audit-kpi-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.audit-kpi-card, +.audit-table-card, +.audit-detail-card, +.audit-filter-bar { + background: rgba(255, 255, 255, 0.96); + border: 1px solid rgba(187, 203, 185, 0.65); + border-radius: 24px; + box-shadow: var(--shadow-soft); +} + +.audit-kpi-card { + padding: 22px; + display: grid; + gap: 14px; +} + +.audit-kpi-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + color: var(--muted); + font-size: 0.76rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.audit-kpi-head .material-symbols-outlined, +.audit-danger, +.audit-secondary { + color: var(--accent); +} + +.audit-danger { + color: #b42318; +} + +.audit-secondary { + color: #006b5f; +} + +.audit-kpi-metric { + display: flex; + align-items: flex-end; + gap: 10px; +} + +.audit-kpi-metric strong { + font-size: 2rem; + line-height: 1; +} + +.audit-kpi-trend { + margin-bottom: 4px; + font-size: 0.78rem; + font-weight: 800; + text-transform: uppercase; +} + +.audit-kpi-trend.is-positive { + color: var(--accent-dark); +} + +.audit-kpi-trend.is-danger { + color: #b42318; +} + +.audit-kpi-bar { + height: 8px; + border-radius: 999px; + background: rgba(231, 241, 228, 1); + overflow: hidden; +} + +.audit-kpi-bar div { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, rgba(37, 211, 102, 1), rgba(0, 109, 47, 1)); +} + +.audit-kpi-user { + display: flex; + align-items: center; + gap: 12px; +} + +.audit-avatar { + width: 42px; + height: 42px; + border-radius: 999px; + background: rgba(231, 241, 228, 1); + color: var(--accent-dark); + display: grid; + place-items: center; + font-weight: 800; +} + +.audit-avatar.small { + width: 32px; + height: 32px; + font-size: 0.85rem; +} + +.audit-avatar.small.is-alert { + background: rgba(255, 218, 214, 1); + color: #b42318; +} + +.audit-kpi-user strong, +.audit-detail-card h3 { + color: var(--text); +} + +.audit-kpi-user span { + display: block; + color: var(--muted-soft); + margin-top: 3px; +} + +.audit-filter-bar { + padding: 18px 20px; + display: grid; + grid-template-columns: auto repeat(4, minmax(0, 180px)) minmax(220px, 1fr) auto; + gap: 16px; + align-items: end; +} + +.audit-filter-title { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 0.76rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.audit-filter-field, +.audit-filter-search { + display: grid; + gap: 6px; +} + +.audit-filter-field span, +.audit-filter-search span { + color: var(--muted); + font-size: 0.7rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.audit-filter-field select, +.audit-filter-search input { + width: 100%; + border-radius: 14px; + border: 1px solid rgba(187, 203, 185, 0.6); + background: rgba(243, 252, 239, 0.9); + padding: 12px 14px; + color: var(--text); +} + +.audit-reset-button { + justify-self: end; + padding: 12px 14px; + border-radius: 14px; + background: transparent; + color: var(--accent); + font-weight: 800; +} + +.audit-layout { + display: grid; + grid-template-columns: minmax(0, 1.8fr) minmax(320px, 0.9fr); + gap: 18px; +} + +.audit-table-card { + overflow: hidden; + position: relative; +} + +.audit-loading-bar { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 3px; + background: linear-gradient(90deg, rgba(37, 211, 102, 0), rgba(37, 211, 102, 1), rgba(0, 109, 47, 0)); + animation: auditPulse 1.1s linear infinite; +} + +.audit-table { + width: 100%; + border-collapse: collapse; +} + +.audit-table th, +.audit-table td { + padding: 18px 20px; + border-bottom: 1px solid rgba(187, 203, 185, 0.35); + text-align: left; +} + +.audit-table th { + background: rgba(237, 246, 233, 0.92); + color: var(--muted); + font-size: 0.74rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.audit-table tbody tr { + transition: background 150ms ease; +} + +.audit-table tbody tr:hover, +.audit-table tbody tr.is-selected { + background: rgba(243, 252, 239, 0.86); +} + +.audit-table tbody tr.is-alert { + background: rgba(255, 218, 214, 0.28); +} + +.audit-table tbody tr.is-alert:hover, +.audit-table tbody tr.is-alert.is-selected { + background: rgba(255, 218, 214, 0.38); +} + +.audit-stamp { + display: grid; + gap: 3px; +} + +.audit-stamp strong { + color: var(--text); +} + +.audit-stamp span { + color: var(--muted-soft); + font-size: 0.76rem; + font-weight: 700; + text-transform: uppercase; +} + +.audit-user-cell { + display: flex; + align-items: center; + gap: 10px; +} + +.audit-action-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 7px 10px; + border-radius: 999px; + font-size: 0.76rem; + font-weight: 800; +} + +.audit-action-pill.tone-default { + background: rgba(0, 109, 47, 0.1); + color: var(--accent-dark); +} + +.audit-action-pill.tone-alert { + background: rgba(186, 35, 24, 0.12); + color: #b42318; +} + +.audit-mono { + font-family: "JetBrains Mono", monospace; + color: var(--muted); + font-size: 0.84rem; +} + +.audit-actions-cell { + text-align: right; +} + +.audit-actions-cell button { + background: transparent; + color: var(--accent); + font-weight: 700; +} + +.audit-pagination { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 16px 20px; + background: rgba(237, 246, 233, 0.92); + color: var(--muted); +} + +.audit-pagination-meta { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; +} + +.audit-page-size { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 0.84rem; + font-weight: 700; +} + +.audit-page-size select { + min-width: 74px; + min-height: 38px; + padding: 8px 12px; + border-radius: 12px; + border: 1px solid rgba(187, 203, 185, 0.72); + background: white; + color: var(--text); + font-weight: 700; +} + +.audit-pagination-buttons { + display: flex; + align-items: center; + gap: 8px; +} + +.audit-pagination-buttons button { + min-width: 36px; + min-height: 36px; + padding: 8px 10px; + border-radius: 12px; + background: transparent; + border: 1px solid rgba(187, 203, 185, 0.65); + color: var(--text); +} + +.audit-pagination-buttons button.is-active { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +.audit-detail-card { + padding: 24px; + display: grid; + align-content: start; + gap: 20px; +} + +@keyframes auditPulse { + 0% { + transform: translateX(-35%); + } + + 100% { + transform: translateX(35%); + } +} + +.code-card pre { + margin: 0; + white-space: pre-wrap; + font-family: "JetBrains Mono", monospace; + font-size: 0.9rem; + line-height: 1.65; +} + +@media (max-width: 960px) { + .page-shell { + flex-direction: column; + } + + .sidebar { + width: auto; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .grid { + grid-template-columns: 1fr; + } + + .content { + padding: 20px; + } + + .hero-card, + .auth-card { + padding: 24px; + } + + .dashboard-app { + grid-template-columns: 1fr; + } + + .dashboard-sidebar { + position: static; + height: auto; + } + + .dashboard-topbar, + .dashboard-content { + padding: 20px; + } + + .dashboard-stat-grid, + .dashboard-two-column, + .dashboard-two-column-bottom, + .conversation-layout, + .overview-grid, + .template-grid, + .permission-grid, + .analytics-bento, + .analytics-metrics-grid, + .templates-grid, + .template-builder-grid { + grid-template-columns: 1fr; + } + + .roles-card-grid, + .roles-bottom-grid, + .roles-create-form, + .audit-kpi-grid, + .audit-layout { + grid-template-columns: 1fr; + } + + .permission-matrix-head, + .roles-audit-item { + grid-template-columns: 1fr; + } + + .permission-matrix-head { + align-items: flex-start; + } + + .audit-filter-bar { + grid-template-columns: 1fr; + } + + .audit-pagination { + flex-direction: column; + align-items: flex-start; + } + + .analytics-live-tail { + align-items: flex-start; + flex-direction: column; + } + + .templates-row-card { + grid-template-columns: 56px minmax(0, 1fr); + } + + .templates-row-date, + .templates-row-status, + .templates-row-actions { + grid-column: 2; + justify-content: flex-start; + text-align: left; + padding-left: 0; + border-left: 0; + } + + .templates-guidelines { + flex-direction: column; + align-items: flex-start; + } + + .templates-guidelines-button { + margin-left: 0; + } + + .template-builder-preview-column { + position: static; + } + + .conversations-layout { + grid-template-columns: 300px minmax(0, 1fr); + } + + .conversations-profile { + display: none; + } + + .security-session-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .auth-container, + .two-factor-shell { + gap: 24px; + } + + .auth-card-enterprise, + .two-factor-card { + padding: 24px; + } + + .auth-secondary-grid { + grid-template-columns: 1fr; + } + + .auth-assist-row, + .two-factor-meta { + flex-direction: column; + align-items: flex-start; + } + + .security-session-head { + flex-direction: column; + } + + .dashboard-topbar, + .page-header, + .page-header.split, + .chat-composer, + .dashboard-topbar-left { + flex-direction: column; + align-items: flex-start; + } + + .contacts-grid, + .campaign-grid, + .message-grid, + .webhook-grid, + .users-grid, + .logs-grid, + .auth-secondary-grid { + grid-template-columns: 1fr; + } + + .analytics-hero, + .analytics-panel-head, + .analytics-table-head, + .analytics-table-foot, + .templates-hero, + .template-builder-hero { + flex-direction: column; + align-items: flex-start; + } + + .dashboard-searchbar { + min-width: 100%; + } + + .templates-filter-meta { + margin-left: 0; + } + + .template-builder-actions, + .template-builder-inline-meta { + width: 100%; + flex-direction: column; + align-items: stretch; + } + + .template-builder-basic-grid { + grid-template-columns: 1fr; + } + + .template-builder-phone-frame { + width: min(100%, 340px); + } + + .conversations-page { + height: auto; + min-height: 0; + } + + .conversations-layout { + grid-template-columns: 1fr; + height: auto; + } + + .conversations-sidebar, + .conversations-thread, + .conversations-profile { + min-height: 0; + } + + .conversations-sidebar { + max-height: 420px; + } + + .conversations-thread-body { + min-height: 420px; + } + + .conversations-thread-head, + .conversations-profile-top { + flex-direction: column; + align-items: flex-start; + } + + .conversation-message-wrap { + max-width: 88%; + } + + .otp-row { + gap: 8px; + } + + .otp-box { + height: 48px; + font-size: 1.05rem; + } +} + +/* Dashboard Fidelity Overrides */ +.material-symbols-outlined { + font-family: "Material Symbols Outlined"; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-smoothing: antialiased; + font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.dashboard-app { + display: block; +} + +.dashboard-sidebar { + position: fixed; + left: 0; + top: 0; + z-index: 50; + width: 260px; + height: 100vh; + padding: 24px 16px; + background: #fff; + border-right: 1px solid var(--border); + box-shadow: 0 1px 3px rgba(21, 30, 22, 0.06); + gap: 8px; + overflow: hidden; +} + +.dashboard-brand { + margin-bottom: 20px; + padding: 0 16px; +} + +.dashboard-brand h1 { + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 24px; + line-height: 32px; + font-weight: 800; +} + +.dashboard-brand p { + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 14px; + line-height: 20px; + color: var(--muted); +} + +.dashboard-primary-action { + margin: 0 8px 24px; + padding: 12px 16px; + border-radius: 8px; + gap: 8px; + background: var(--accent); + box-shadow: none; +} + +.dashboard-sidebar-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 8px 12px; + scrollbar-width: thin; + scrollbar-color: rgba(108, 123, 107, 0.45) transparent; +} + +.dashboard-sidebar-scroll::-webkit-scrollbar { + width: 8px; +} + +.dashboard-sidebar-scroll::-webkit-scrollbar-thumb { + background: rgba(108, 123, 107, 0.35); + border-radius: 999px; +} + +.dashboard-nav-link, +.dashboard-logout-button { + display: flex; + align-items: center; + padding: 10px 16px; + border-radius: 8px; + gap: 12px; + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + transition: background-color 150ms ease, transform 150ms ease; +} + +.dashboard-nav-link.is-active { + background: var(--accent-bright); + color: var(--accent-dark); +} + +.dashboard-nav-secondary { + display: grid; + gap: 2px; + margin-top: 10px; +} + +.dashboard-subnav-link { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + border-radius: 8px; + color: var(--muted); + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 600; +} + +.dashboard-nav-link .material-symbols-outlined, +.dashboard-subnav-link .material-symbols-outlined, +.dashboard-logout-button .material-symbols-outlined, +.dashboard-primary-action .material-symbols-outlined { + flex: 0 0 28px; + width: 28px; + min-width: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 22px; + line-height: 1; +} + +.dashboard-subnav-link:hover, +.dashboard-subnav-link.is-active { + background: #edf6e9; + color: var(--accent); +} + +.dashboard-sidebar-footer { + border-top-color: rgba(187, 203, 185, 1); + margin-top: 18px; + padding-top: 16px; +} + +.dashboard-settings-accordion { + display: grid; + gap: 6px; +} + +.dashboard-settings-accordion summary { + list-style: none; +} + +.dashboard-settings-accordion summary::-webkit-details-marker { + display: none; +} + +.dashboard-nav-link-main { + display: inline-flex; + align-items: center; + gap: 12px; +} + +.dashboard-settings-caret { + margin-left: auto; + transition: transform 150ms ease; +} + +.dashboard-settings-accordion[open] .dashboard-settings-caret { + transform: rotate(180deg); +} + +.dashboard-settings-subnav { + display: grid; + gap: 4px; + margin: 8px 0 12px; + padding-left: 14px; + border-left: 1px solid rgba(187, 203, 185, 0.9); +} + +.dashboard-settings-link { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: 10px; + color: var(--muted); + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 11px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 700; +} + +.dashboard-settings-link .material-symbols-outlined { + flex: 0 0 20px; + width: 20px; + min-width: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 18px; + line-height: 1; +} + +.dashboard-settings-link:hover, +.dashboard-settings-link.is-active { + background: #edf6e9; + color: var(--accent); +} + +.dashboard-main { + margin-left: 260px; +} + +.dashboard-topbar { + justify-content: space-between; + height: 64px; + padding: 0 32px; + background: #f3fcef; + border-bottom: 1px solid var(--border); + box-shadow: 0 1px 3px rgba(21, 30, 22, 0.05); +} + +.dashboard-topbar-left { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + flex-wrap: nowrap; + gap: 16px; +} + +.dashboard-topbar-left h2 { + margin: 0; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 18px; + line-height: 24px; + font-weight: 700; + color: var(--accent); +} + +.dashboard-topbar-divider { + width: 1px; + height: 24px; + flex-shrink: 0; + background: rgba(187, 203, 185, 0.95); +} + +.dashboard-searchbar { + flex: 1 1 280px; + min-width: 0; + max-width: 420px; + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 10px; + border: 1px solid var(--border); + background: #e7f1e4; +} + +.dashboard-searchbar .material-symbols-outlined { + color: var(--muted); + font-size: 20px; +} + +.dashboard-searchbar input { + width: 100%; + padding: 0; + border: 0; + background: transparent; + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 14px; + line-height: 20px; + color: var(--text); + outline: none; +} + +.language-switcher-compact { + display: inline-flex; + flex-shrink: 0; + gap: 0; + padding: 4px; + border-radius: 999px; + border: 1px solid var(--border); + background: #e7f1e4; +} + +.language-switcher-compact button { + min-width: 44px; + padding: 6px 12px; + border-radius: 999px; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + box-shadow: none; +} + +.dashboard-topbar-actions { + gap: 24px; + display: flex; + align-items: center; + flex-shrink: 0; +} + +.dashboard-icon-button { + width: auto; + height: auto; + padding: 0; + border: 0; + background: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: none; +} + +.dashboard-icon-button svg { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + font-size: 28px; + line-height: 1; + color: var(--muted); +} + +.dashboard-icon-button:hover svg { + color: var(--accent-dark); +} + +.dashboard-profile { + gap: 12px; + padding-left: 24px; + border-left: 1px solid var(--border); +} + +.dashboard-profile strong { + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 14px; + line-height: 20px; + font-weight: 700; + color: var(--text); +} + +.dashboard-profile span { + font-size: 10px; + line-height: 16px; + letter-spacing: 0.05em; +} + +.dashboard-avatar { + border: 1px solid var(--accent); + background: #fff; + color: var(--accent); +} + +.dashboard-content { + padding: 32px; +} + +.dashboard-overview-layout { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); + gap: 24px; +} + +.dashboard-overview-main, +.dashboard-overview-side { + display: grid; + gap: 24px; + align-content: start; +} + +.dashboard-stat-grid { + gap: 24px; +} + +.surface-card { + border-radius: 12px; + background: #fff; + border: 1px solid var(--border); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); + padding: 20px; +} + +.dashboard-kpi-card { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dashboard-kpi-top { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.dashboard-kpi-icon { + display: inline-flex; + padding: 6px; + border-radius: 8px; + color: var(--accent); + background: rgba(37, 211, 102, 0.16); +} + +.dashboard-kpi-metric { + display: flex; + align-items: baseline; + gap: 10px; +} + +.dashboard-kpi-metric h3 { + margin: 0; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 32px; + line-height: 40px; + font-weight: 700; + color: var(--text); +} + +.dashboard-kpi-delta { + display: inline-flex; + align-items: center; + gap: 2px; + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 14px; + line-height: 20px; + font-weight: 700; +} + +.dashboard-kpi-delta.success { + color: #25d366; +} + +.dashboard-kpi-delta.warning { + color: #ef4444; +} + +.analytics-page { + display: grid; + gap: 24px; +} + +.analytics-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; +} + +.analytics-heading { + margin: 0 0 10px; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: clamp(2rem, 4vw, 2.85rem); + line-height: 1.08; + letter-spacing: -0.03em; +} + +.analytics-copy { + margin: 0; + max-width: 760px; + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 16px; + line-height: 24px; + color: var(--muted); +} + +.analytics-hero-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.analytics-ghost-button, +.analytics-pagination button { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid var(--border); + border-radius: 10px; + background: #fff; + color: var(--muted); + box-shadow: none; +} + +.analytics-ghost-button { + padding: 10px 16px; + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 700; +} + +.analytics-bento { + display: grid; + grid-template-columns: minmax(280px, 380px) minmax(0, 1fr); + gap: 24px; + align-items: start; +} + +.analytics-side-stack { + display: grid; + gap: 24px; +} + +.analytics-panel { + display: grid; + gap: 24px; +} + +.analytics-panel-head, +.analytics-table-head, +.analytics-metric-head, +.analytics-worker-meta, +.analytics-table-foot, +.analytics-live-tail { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.analytics-panel-head h3, +.analytics-table-head h3, +.analytics-throughput-card h3, +.analytics-live-tail-copy h3 { + margin: 0; + font-size: 18px; + line-height: 24px; + font-weight: 700; +} + +.analytics-live-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(37, 211, 102, 0.12); + color: #15703c; + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 11px; + line-height: 16px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 800; +} + +.analytics-live-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: #25d366; + box-shadow: 0 0 0 6px rgba(37, 211, 102, 0.12); + animation: analyticsPulse 1.8s ease-in-out infinite; +} + +.analytics-queue-stack, +.analytics-worker-list { + display: grid; + gap: 14px; +} + +.analytics-queue-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px; + border-radius: 12px; + background: #edf6e9; + border-left: 4px solid transparent; +} + +.analytics-queue-card p, +.analytics-queue-card strong, +.analytics-throughput-card p, +.analytics-live-tail-copy p { + margin: 0; +} + +.analytics-queue-card p, +.analytics-worker-health h4, +.analytics-table thead th, +.analytics-metric-head span:first-child { + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + font-weight: 700; +} + +.analytics-queue-card strong { + display: block; + margin-top: 4px; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 32px; + line-height: 40px; + font-weight: 700; + color: var(--text); +} + +.analytics-queue-card.is-info { + border-left-color: #3b82f6; +} + +.analytics-queue-card.is-primary { + border-left-color: var(--accent); +} + +.analytics-queue-card.is-error { + border-left-color: #ef4444; +} + +.analytics-queue-icon { + font-size: 32px; + color: var(--muted); +} + +.analytics-queue-card.is-info .analytics-queue-icon { + color: #3b82f6; +} + +.analytics-queue-card.is-primary .analytics-queue-icon { + color: var(--accent); +} + +.analytics-queue-card.is-error .analytics-queue-icon, +.analytics-queue-card.is-error strong { + color: #ef4444; +} + +.analytics-queue-icon.is-spinning { + animation: analyticsSpin 2s linear infinite; +} + +.analytics-worker-health { + display: grid; + gap: 16px; + padding-top: 24px; + border-top: 1px solid rgba(187, 203, 185, 0.9); +} + +.analytics-worker-health h4 { + margin: 0; +} + +.analytics-worker-meta span, +.analytics-worker-meta strong, +.analytics-table-head span, +.analytics-table-foot span, +.analytics-live-tail-copy p, +.analytics-metric-value span { + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 14px; + line-height: 20px; +} + +.analytics-worker-meta strong.is-success, +.analytics-metric-value span.is-success { + color: #15703c; +} + +.analytics-worker-meta strong.is-warning, +.analytics-metric-value span.is-warning { + color: #f59e0b; +} + +.analytics-metric-value span.is-muted { + color: var(--muted); +} + +.analytics-progress-track { + width: 100%; + height: 8px; + overflow: hidden; + border-radius: 999px; + background: #dce5d8; +} + +.analytics-progress-fill { + height: 100%; + border-radius: inherit; + background: var(--accent); +} + +.analytics-progress-fill.is-success { + background: #25d366; +} + +.analytics-progress-fill.is-warning { + background: #f59e0b; +} + +.analytics-throughput-card { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 20px; + border-radius: 14px; + border: 1px solid rgba(0, 109, 47, 0.18); + background: linear-gradient(180deg, rgba(37, 211, 102, 0.12), rgba(255, 255, 255, 0.9)); +} + +.analytics-throughput-icon, +.analytics-live-tail-icon { + flex: 0 0 auto; + width: 48px; + height: 48px; + display: grid; + place-items: center; + border-radius: 50%; + background: #fff; + color: var(--accent); + box-shadow: 0 4px 14px rgba(21, 30, 22, 0.08); +} + +.analytics-table-card { + overflow: hidden; + padding: 0; +} + +.analytics-table-head, +.analytics-table-foot { + padding: 20px 24px; +} + +.analytics-table-head { + border-bottom: 1px solid rgba(187, 203, 185, 0.9); +} + +.analytics-table-head span { + padding: 6px 10px; + border-radius: 10px; + background: #e7f1e4; + color: var(--muted); +} + +.analytics-table-scroll { + overflow-x: auto; +} + +.analytics-table { + width: 100%; + border-collapse: collapse; +} + +.analytics-table thead { + background: #edf6e9; +} + +.analytics-table thead th { + padding: 14px 24px; + text-align: left; +} + +.analytics-table tbody tr { + transition: background-color 150ms ease; +} + +.analytics-table tbody tr:hover { + background: rgba(237, 246, 233, 0.72); +} + +.analytics-table tbody td { + padding: 18px 24px; + border-top: 1px solid rgba(187, 203, 185, 0.75); + vertical-align: middle; +} + +.analytics-table-time { + white-space: nowrap; + font-family: "JetBrains Mono", monospace; + font-size: 13px; + line-height: 20px; + color: var(--muted); +} + +.analytics-action-cell, +.analytics-live-tail-copy { + display: grid; + gap: 4px; +} + +.analytics-action-cell strong, +.analytics-metric-value strong { + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-weight: 700; + color: var(--text); +} + +.analytics-action-cell span { + font-size: 11px; + line-height: 16px; + color: var(--muted); +} + +.analytics-actor-cell { + display: inline-flex; + align-items: center; + gap: 10px; + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 14px; + line-height: 20px; +} + +.analytics-actor-badge { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 999px; + background: #8cf1e1; + color: #006f64; + font-size: 11px; + font-weight: 800; +} + +.analytics-actor-icon { + color: var(--muted); + font-size: 20px; +} + +.analytics-status-pill { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 11px; + line-height: 16px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 800; +} + +.analytics-status-pill.is-success { + background: rgba(37, 211, 102, 0.12); + color: #15703c; +} + +.analytics-status-pill.is-warning { + background: rgba(245, 158, 11, 0.12); + color: #b45309; +} + +.analytics-status-pill.is-error { + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; +} + +.analytics-payload-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + border-radius: 10px; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + box-shadow: none; +} + +.analytics-payload-button:hover { + background: #edf6e9; + color: var(--accent); +} + +.analytics-payload-button.is-error:hover { + color: #b91c1c; +} + +.analytics-payload-button.is-success:hover { + color: #15703c; +} + +.analytics-payload-button.is-primary:hover { + color: var(--accent); +} + +.analytics-table-foot { + border-top: 1px solid rgba(187, 203, 185, 0.9); +} + +.analytics-pagination { + display: flex; + gap: 8px; +} + +.analytics-pagination button { + width: 40px; + height: 40px; + justify-content: center; + padding: 0; +} + +.analytics-live-tail { + padding: 20px 24px; + border-radius: 14px; + border: 1px solid rgba(187, 203, 185, 0.95); + background: #edf6e9; +} + +.analytics-live-tail-copy { + flex: 1; +} + +.analytics-console-button { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 14px 18px; + border-radius: 12px; + background: var(--accent); + color: white; + box-shadow: 0 8px 18px rgba(0, 109, 47, 0.18); +} + +.analytics-metrics-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 24px; +} + +.analytics-metric-card { + display: grid; + gap: 16px; +} + +.analytics-metric-head .material-symbols-outlined { + color: var(--accent); +} + +.analytics-metric-value { + display: flex; + align-items: baseline; + gap: 10px; +} + +.analytics-metric-value strong { + font-size: 32px; + line-height: 40px; +} + +.analytics-mini-bars { + display: flex; + align-items: flex-end; + gap: 6px; + height: 56px; +} + +.analytics-mini-bars span { + flex: 1; + border-radius: 6px 6px 0 0; + background: #dce5d8; +} + +.analytics-mini-bars span.is-primary { + background: var(--accent); +} + +.analytics-mini-bars.is-memory span { + background: rgba(0, 109, 47, 0.16); +} + +.analytics-mini-bars.is-memory span:last-child { + background: var(--accent); +} + +.analytics-metric-progress { + height: 16px; +} + +.templates-page, +.template-builder-page { + display: grid; + gap: 28px; +} + +.templates-hero, +.template-builder-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; +} + +.templates-heading, +.template-builder-heading { + margin: 0 0 10px; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: clamp(2rem, 4vw, 2.7rem); + line-height: 1.08; + letter-spacing: -0.03em; + color: var(--text); +} + +.templates-copy, +.template-builder-copy { + margin: 0; + max-width: 760px; + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 16px; + line-height: 24px; + color: var(--muted); +} + +.templates-create-button, +.template-builder-submit-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 14px 20px; + border-radius: 12px; + background: var(--accent); + color: #fff; + box-shadow: 0 14px 28px rgba(0, 109, 47, 0.16); + font-weight: 700; +} + +.templates-filter-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px; +} + +.templates-filter-pill, +.templates-guidelines-button, +.template-builder-draft-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-radius: 999px; + border: 1px solid rgba(187, 203, 185, 0.95); + background: #fff; + color: var(--muted); + box-shadow: none; +} + +.templates-filter-pill-select { + gap: 10px; + padding-right: 12px; +} + +.templates-filter-pill-select span { + color: var(--muted); + font-size: 14px; + font-weight: 600; +} + +.templates-filter-pill-select select { + border: 0; + background: transparent; + color: var(--text); + font: inherit; + outline: none; +} + +.templates-filter-apply { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 18px; + border-radius: 999px; + border: 0; + background: rgba(0, 109, 47, 0.08); + color: var(--accent); + font-weight: 700; +} + +.templates-filter-pill .material-symbols-outlined { + font-size: 18px; +} + +.templates-filter-meta { + margin-left: auto; + color: var(--muted); + font-size: 14px; +} + +.templates-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 24px; +} + +.templates-card, +.templates-row-card, +.template-builder-card { + padding: 24px; +} + +.templates-card { + border: 1px solid transparent; + transition: border-color 150ms ease, transform 150ms ease; +} + +.templates-card:hover, +.templates-row-card:hover { + border-color: rgba(0, 109, 47, 0.28); + transform: translateY(-1px); +} + +.templates-card-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.templates-status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 0 14px; + border-radius: 999px; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 800; +} + +.templates-status-pill.is-approved { + background: rgba(37, 211, 102, 0.18); + color: #005523; +} + +.templates-status-pill.is-pending { + background: #ffdbcf; + color: #763319; +} + +.templates-status-pill.is-rejected { + background: #ffdad6; + color: #93000a; +} + +.templates-overflow-button, +.templates-edit-button, +.templates-row-actions button, +.template-builder-toolbar button, +.template-builder-delete-button { + width: 40px; + height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: 12px; + background: transparent; + color: var(--muted); + box-shadow: none; +} + +.templates-overflow-button { + opacity: 0; + transition: opacity 150ms ease; +} + +.templates-card:hover .templates-overflow-button { + opacity: 1; +} + +.templates-card h2, +.templates-row-main h2, +.template-builder-card h2, +.templates-guidelines-copy h3 { + margin: 0; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 18px; + line-height: 24px; + color: var(--text); +} + +.templates-category { + margin: 4px 0 18px; + color: #006b5f; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 800; +} + +.templates-preview-box { + position: relative; + min-height: 128px; + padding: 16px; + border-radius: 14px; + background: #f3fcef; + overflow: hidden; +} + +.templates-preview-box p { + margin: 0; + color: var(--muted); + font-size: 14px; + line-height: 1.65; + font-style: italic; +} + +.templates-preview-fade { + position: absolute; + inset: auto 0 0; + height: 48px; + background: linear-gradient(180deg, rgba(243, 252, 239, 0), #f3fcef 90%); +} + +.templates-card-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid rgba(220, 229, 216, 0.95); +} + +.templates-update-meta { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 14px; +} + +.templates-update-meta.is-alert { + color: #b91c1c; +} + +.templates-edit-button { + color: var(--accent); +} + +.templates-row-card { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 56px minmax(0, 1fr) 160px 160px auto; + align-items: center; + gap: 20px; +} + +.templates-row-icon { + width: 48px; + height: 48px; + border-radius: 14px; + background: #e7f1e4; + color: var(--accent); + display: grid; + place-items: center; +} + +.templates-row-icon .material-symbols-outlined { + font-size: 28px; +} + +.templates-row-meta { + display: flex; + align-items: center; + gap: 10px; + margin-top: 8px; +} + +.templates-row-meta span { + color: #006b5f; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.templates-row-meta i { + width: 4px; + height: 4px; + border-radius: 999px; + background: rgba(108, 123, 107, 0.6); +} + +.templates-row-meta p, +.template-builder-button-row p, +.template-builder-phone-contact span { + margin: 0; + color: var(--muted); + font-size: 14px; +} + +.templates-row-date { + text-align: center; +} + +.templates-row-date span { + display: block; + color: var(--muted); + font-size: 14px; +} + +.templates-row-date strong, +.template-builder-button-row strong { + font-size: 16px; + line-height: 24px; +} + +.templates-row-status { + display: flex; + justify-content: center; + padding: 0 16px; + border-left: 1px solid rgba(220, 229, 216, 0.95); +} + +.templates-row-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.templates-row-actions button:hover, +.templates-overflow-button:hover, +.templates-edit-button:hover, +.template-builder-toolbar button:hover, +.template-builder-delete-button:hover { + background: #edf6e9; +} + +.templates-row-actions button.is-primary { + color: var(--accent); +} + +.templates-guidelines { + display: flex; + align-items: center; + gap: 28px; + padding: 30px; + border-radius: 24px; + background: #e7f1e4; + border: 1px dashed rgba(108, 123, 107, 0.5); +} + +.templates-guidelines-icon { + width: 96px; + height: 96px; + border-radius: 999px; + background: #dce5d8; + color: var(--accent); + display: grid; + place-items: center; + flex: 0 0 auto; +} + +.templates-guidelines-icon .material-symbols-outlined { + font-size: 46px; +} + +.templates-guidelines-copy p { + margin: 8px 0 0; + max-width: 640px; + color: var(--muted); + line-height: 1.7; +} + +.templates-guidelines-button { + margin-left: auto; + border-radius: 12px; + padding: 12px 18px; + font-weight: 700; +} + +.template-builder-actions { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +.template-builder-draft-button { + border-radius: 12px; + padding: 12px 18px; + color: #6c7b6b; + font-weight: 700; +} + +.template-builder-grid { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(340px, 0.95fr); + gap: 32px; + align-items: start; +} + +.template-builder-form-column { + display: grid; + gap: 24px; +} + +.template-builder-card { + display: grid; + gap: 22px; +} + +.template-builder-card-head, +.template-builder-card-topline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.template-builder-card-head { + justify-content: flex-start; +} + +.template-builder-card-head .material-symbols-outlined { + color: var(--accent); +} + +.template-builder-basic-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 22px; +} + +.template-builder-field { + display: grid; + gap: 10px; +} + +.template-builder-field-full { + grid-column: 1 / -1; +} + +.template-builder-field span, +.template-builder-inline-meta span, +.template-builder-button-row strong { + color: #64748b; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 800; +} + +.template-builder-field input, +.template-builder-field select, +.template-builder-field textarea { + width: 100%; + padding: 14px 16px; + border-radius: 12px; + border: 1px solid rgba(226, 232, 240, 1); + background: #f3fcef; + color: var(--text); + outline: none; +} + +.template-builder-field textarea { + resize: vertical; + min-height: 210px; + line-height: 1.7; +} + +.template-builder-field input:focus, +.template-builder-field select:focus, +.template-builder-field textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(37, 211, 102, 0.12); +} + +.template-builder-toolbar { + display: flex; + gap: 8px; +} + +.template-builder-toolbar button { + background: #e7f1e4; +} + +.template-builder-content-stack, +.template-builder-button-stack { + display: grid; + gap: 18px; +} + +.template-builder-inline-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.template-builder-inline-meta span { + text-transform: none; + letter-spacing: 0; +} + +.template-builder-inline-meta button { + border: 0; + background: transparent; + padding: 0; + color: var(--accent); + font-weight: 700; +} + +.template-builder-button-row { + display: flex; + align-items: center; + gap: 16px; + padding: 18px; + border-radius: 14px; + border: 1px dashed rgba(108, 123, 107, 0.34); + background: #f3fcef; +} + +.template-builder-button-row .material-symbols-outlined { + color: #6c7b6b; +} + +.template-builder-button-row div { + flex: 1; +} + +.template-builder-button-row input { + width: 100%; + margin-top: 8px; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid rgba(226, 232, 240, 1); + background: #fff; + color: var(--text); +} + +.template-builder-button-row input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(37, 211, 102, 0.12); + outline: none; +} + +.template-builder-delete-button { + color: #ba1a1a; +} + +.template-builder-add-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + padding: 14px 18px; + border-radius: 14px; + border: 2px dashed rgba(0, 109, 47, 0.26); + background: rgba(0, 109, 47, 0.03); + color: var(--accent); + font-weight: 700; +} + +.template-builder-preview-column { + position: sticky; + top: 92px; +} + +.template-builder-preview-shell { + display: grid; + justify-items: center; + gap: 26px; +} + +.template-builder-phone-frame { + width: 340px; + height: 680px; + padding: 10px; + border-radius: 48px; + background: #0f172a; + box-shadow: 0 26px 70px rgba(15, 23, 42, 0.28); +} + +.template-builder-phone-status { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 22px 10px; + color: #fff; + font-size: 12px; + font-weight: 700; +} + +.template-builder-phone-status div { + display: flex; + gap: 4px; +} + +.template-builder-phone-status .material-symbols-outlined { + font-size: 15px; +} + +.template-builder-phone-screen { + height: calc(100% - 28px); + overflow: hidden; + border-radius: 38px; + background: linear-gradient(180deg, rgba(229, 221, 213, 0.95), rgba(236, 229, 221, 0.96)); + position: relative; +} + +.template-builder-phone-screen::before { + content: ""; + position: absolute; + inset: 0; + background-image: radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.28), transparent 24%), + radial-gradient(circle at 80% 0%, rgba(255, 255, 255, 0.16), transparent 22%); + pointer-events: none; +} + +.template-builder-phone-header { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto auto auto; + align-items: center; + gap: 10px; + min-height: 64px; + padding: 0 16px; + background: #075e54; + color: #fff; +} + +.template-builder-phone-avatar { + width: 40px; + height: 40px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.2); + display: grid; + place-items: center; + font-weight: 800; +} + +.template-builder-phone-contact strong, +.template-builder-chat-bubble strong { + display: block; +} + +.template-builder-chat-area { + position: relative; + z-index: 1; + height: calc(100% - 64px); + padding: 18px 14px 88px; +} + +.template-builder-chat-date { + width: fit-content; + margin: 0 auto 18px; + padding: 6px 12px; + border-radius: 8px; + background: #d1e5f0; + color: #556b77; + font-size: 11px; + font-weight: 700; +} + +.template-builder-chat-stack { + max-width: 86%; +} + +.template-builder-chat-bubble { + padding: 14px; + border-radius: 0 18px 18px 18px; + background: #fff; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08); +} + +.template-builder-chat-bubble p { + margin: 0; + color: #1f2937; + font-size: 14px; + line-height: 1.5; +} + +.template-builder-chat-bubble strong { + display: inline; + color: var(--accent); +} + +.template-builder-chat-bubble span { + color: #2563eb; + text-decoration: underline; +} + +.template-builder-chat-meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + margin-top: 10px; + color: #6b7280; +} + +.template-builder-chat-meta .material-symbols-outlined { + font-size: 14px; + color: #3b82f6; +} + +.template-builder-chat-reply { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + margin-top: 2px; + padding: 12px 14px; + border-radius: 0 0 18px 18px; + border: 0; + background: #fff; + color: #2563eb; + font-weight: 700; +} + +.template-builder-input-row { + position: absolute; + right: 12px; + bottom: 14px; + left: 12px; + display: flex; + align-items: center; + gap: 10px; +} + +.template-builder-input-shell { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + min-height: 42px; + padding: 0 16px; + border-radius: 999px; + background: #fff; + box-shadow: 0 8px 16px rgba(15, 23, 42, 0.08); + color: #9ca3af; +} + +.template-builder-input-placeholder { + flex: 1; + font-size: 14px; +} + +.template-builder-mic-button { + width: 42px; + height: 42px; + border-radius: 999px; + background: #075e54; + color: #fff; + display: grid; + place-items: center; + box-shadow: 0 8px 16px rgba(15, 23, 42, 0.14); +} + +.template-builder-preview-toggle { + display: inline-flex; + gap: 8px; + padding: 6px; + border-radius: 999px; + border: 1px solid rgba(226, 232, 240, 1); + background: #fff; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06); +} + +.template-builder-preview-toggle button { + min-width: 108px; + padding: 10px 18px; + border-radius: 999px; + border: 0; + background: transparent; + color: #64748b; + font-weight: 700; +} + +.template-builder-preview-toggle button.is-active { + background: var(--accent); + color: #fff; +} + +.conversations-page { + --conversation-header-height: 86px; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.conversations-layout { + display: grid; + grid-template-columns: 320px minmax(0, 1fr) 288px; + gap: 0; + height: 100%; + min-height: 0; + overflow: hidden; + border-radius: 22px; + border: 1px solid rgba(187, 203, 185, 0.95); + background: #fff; + box-shadow: 0 18px 46px rgba(15, 23, 42, 0.08); +} + +.conversations-sidebar, +.conversations-thread, +.conversations-profile { + height: 100%; + min-height: 0; + border: 0; + border-radius: 0; + box-shadow: none; + padding: 0; + background: #fff; +} + +.conversations-sidebar { + display: grid; + grid-template-rows: var(--conversation-header-height) minmax(0, 1fr); + border-right: 1px solid rgba(187, 203, 185, 0.95); +} + +.conversations-profile { + border-left: 1px solid rgba(187, 203, 185, 0.95); +} + +.conversations-filter-tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: center; + gap: 8px; + min-height: var(--conversation-header-height); + padding: 16px; + border-bottom: 1px solid rgba(187, 203, 185, 0.95); +} + +.conversations-filter-tabs button { + min-height: 34px; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--muted); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.conversations-filter-tabs button.is-active { + background: rgba(37, 211, 102, 0.18); + color: #005523; +} + +.conversations-list, +.conversations-thread-body, +.conversations-profile-scroll { + min-height: 0; + overflow-y: auto; +} + +.conversations-list { + height: auto; +} + +.conversation-list-item { + position: relative; + display: flex; + gap: 14px; + padding: 18px 18px 18px 20px; + border-bottom: 1px solid rgba(187, 203, 185, 0.75); + cursor: pointer; + transition: background-color 150ms ease; +} + +.conversation-list-item:hover { + background: #edf6e9; +} + +.conversation-list-item.is-active { + background: #edf6e9; +} + +.conversation-active-rail { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: var(--accent); +} + +.conversation-avatar { + width: 48px; + height: 48px; + flex: 0 0 auto; + display: grid; + place-items: center; + border-radius: 999px; + background: linear-gradient(135deg, rgba(37, 211, 102, 0.25), rgba(0, 109, 47, 0.18)); + color: var(--accent); + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 15px; + font-weight: 800; +} + +.conversation-avatar.is-large { + width: 40px; + height: 40px; + font-size: 13px; +} + +.conversation-avatar.is-profile { + width: 96px; + height: 96px; + font-size: 28px; + box-shadow: 0 0 0 8px rgba(37, 211, 102, 0.08); +} + +.conversation-list-main { + min-width: 0; + flex: 1; +} + +.conversation-list-topline { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; + margin-bottom: 4px; +} + +.conversation-list-topline h3, +.conversations-thread-contact h2, +.conversations-profile-top h3 { + margin: 0; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 16px; + line-height: 22px; +} + +.conversation-list-topline span, +.conversation-meta span, +.conversations-online-row span, +.conversations-profile-top p, +.conversations-detail-list p, +.conversations-activity-item p { + font-size: 12px; + line-height: 16px; + color: var(--muted); +} + +.conversation-list-topline span.is-recent { + color: var(--accent); + font-weight: 700; +} + +.conversation-list-main p { + margin: 0; + color: #3c4a3d; + font-size: 14px; + line-height: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.conversation-list-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.conversation-list-pill { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 10px; + line-height: 14px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.conversation-list-pill.is-info { + background: rgba(59, 130, 246, 0.12); + color: #1d4ed8; +} + +.conversation-list-pill.is-success { + background: rgba(37, 211, 102, 0.12); + color: #15703c; +} + +.conversation-list-pill.is-warning { + background: rgba(245, 158, 11, 0.12); + color: #b45309; +} + +.conversation-list-pill.is-muted { + background: #dce5d8; + color: #3c4a3d; +} + +.conversations-thread { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + min-height: 0; + overflow: hidden; + background: #f8f9fa; +} + +.conversations-thread-head, +.conversations-composer { + background: #fff; +} + +.conversations-thread-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: var(--conversation-header-height); + padding: 16px 24px; + border-bottom: 1px solid rgba(187, 203, 185, 0.95); +} + +.conversations-thread-contact, +.conversations-thread-actions, +.conversations-profile-actions, +.conversations-detail-list div, +.conversations-composer-shell { + display: flex; + align-items: center; +} + +.conversations-thread-contact { + gap: 14px; +} + +.conversations-online-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; +} + +.conversations-online-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: #25d366; +} + +.conversations-online-dot.is-offline { + background: #94a3b8; +} + +.conversations-thread-actions { + gap: 8px; +} + +.conversations-thread-actions button, +.conversations-profile-actions button { + width: 40px; + height: 40px; + padding: 0; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--muted); + box-shadow: none; +} + +.conversations-thread-actions button:hover, +.conversations-profile-actions button:hover { + background: #edf6e9; + color: var(--accent); +} + +.conversations-thread-body { + display: flex; + flex-direction: column; + min-height: 0; + gap: 18px; + padding: 24px; + background: #f8f9fa; +} + +.conversations-empty-state { + display: grid; + justify-items: center; + gap: 8px; + padding: 32px 20px; + text-align: center; + color: var(--muted); +} + +.conversations-empty-state .material-symbols-outlined { + font-size: 28px; +} + +.conversations-empty-state p { + margin: 0; + font-size: 14px; +} + +.conversation-message-wrap { + display: flex; + flex-direction: column; + max-width: 70%; +} + +.conversation-message-wrap.is-outgoing { + align-self: flex-end; + align-items: flex-end; +} + +.conversation-message-wrap.is-incoming { + align-items: flex-start; +} + +.conversation-bubble { + padding: 14px; + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06); +} + +.conversation-bubble.is-incoming { + border: 1px solid rgba(187, 203, 185, 0.95); + border-radius: 0 18px 18px 18px; + background: #fff; +} + +.conversation-bubble.is-outgoing { + border-radius: 18px 0 18px 18px; + background: var(--accent); + color: #fff; +} + +.conversation-bubble.is-outgoing p, +.conversation-bubble.is-outgoing strong { + color: #f8fff9; +} + +.conversation-bubble.is-rich { + display: grid; + gap: 10px; +} + +.conversation-bubble p, +.conversation-bubble strong { + margin: 0; + font-size: 14px; + line-height: 22px; +} + +.conversation-bubble.is-rich strong { + font-size: 15px; +} + +.conversation-meta { + display: flex; + align-items: center; + gap: 4px; + margin-top: 6px; +} + +.conversation-meta.is-outgoing { + justify-content: flex-end; +} + +.conversation-meta .material-symbols-outlined { + font-size: 15px; + color: var(--accent); +} + +.conversation-system-divider { + display: flex; + justify-content: center; + margin: 6px 0; +} + +.conversation-system-divider span { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 14px; + border-radius: 999px; + background: #dce5d8; + color: var(--muted); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.conversations-composer { + display: grid; + gap: 12px; + flex-shrink: 0; + padding: 16px; + border-top: 1px solid rgba(187, 203, 185, 0.95); +} + +.conversations-composer-tools { + display: flex; + flex-wrap: nowrap; + gap: 10px; + overflow-x: auto; +} + +.conversations-composer-tools button, +.conversations-profile-footer button { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 999px; + border: 1px solid rgba(187, 203, 185, 0.95); + background: #fff; + color: #3c4a3d; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; + box-shadow: none; +} + +.conversations-composer-tools button { + flex: 0 0 auto; + padding: 8px 14px; + white-space: nowrap; +} + +.conversations-composer-tools button.is-active { + border-color: rgba(0, 109, 47, 0.3); + background: rgba(0, 109, 47, 0.05); + color: var(--accent); +} + +.conversations-composer-suggestions { + display: flex; + flex-wrap: nowrap; + gap: 10px; + overflow-x: auto; +} + +.conversation-suggestion-chip { + flex: 0 0 auto; + min-height: 34px; + padding: 0 14px; + border: 1px solid rgba(0, 109, 47, 0.16); + border-radius: 999px; + background: rgba(0, 109, 47, 0.04); + color: var(--accent); + font-size: 12px; + font-weight: 700; + box-shadow: none; + white-space: nowrap; +} + +.conversation-suggestion-chip:hover { + background: rgba(0, 109, 47, 0.08); +} + +.conversations-composer-shell { + gap: 10px; + padding: 10px 14px; + border-radius: 16px; + background: #edf6e9; +} + +.conversations-composer-shell > button, +.conversations-send-button { + width: 38px; + height: 38px; + padding: 0; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--muted); + box-shadow: none; +} + +.conversations-composer-shell textarea { + flex: 1; + min-height: 24px; + max-height: 128px; + resize: none; + border: 0; + background: transparent; + color: var(--text); + outline: none; +} + +.conversations-send-button { + background: var(--accent); + color: #fff; +} + +.conversations-profile { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.conversations-profile-top, +.conversations-profile-footer { + padding: 24px; +} + +.conversations-profile-top { + display: grid; + justify-items: center; + text-align: center; + gap: 10px; + border-bottom: 1px solid rgba(187, 203, 185, 0.95); +} + +.conversations-profile-actions { + gap: 8px; + margin-top: 6px; +} + +.conversations-profile-scroll { + padding: 24px; + display: grid; + gap: 28px; +} + +.conversations-profile-section h4 { + margin: 0 0 14px; + color: var(--muted); + font-size: 12px; + line-height: 16px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.conversations-detail-list, +.conversations-activity-list { + display: grid; + gap: 14px; +} + +.conversations-detail-list div { + gap: 10px; +} + +.conversations-detail-list .material-symbols-outlined { + color: var(--muted); + font-size: 18px; +} + +.conversations-detail-list p { + margin: 0; + color: var(--text); +} + +.conversations-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.conversations-tags span, +.conversations-tags button { + min-height: 28px; + padding: 0 10px; + border-radius: 8px; + border: 1px solid rgba(187, 203, 185, 0.95); + background: #e2ebde; + color: #3c4a3d; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.conversations-tags button { + background: rgba(0, 109, 47, 0.1); + color: var(--accent); + box-shadow: none; +} + +.conversations-activity-item { + display: flex; + gap: 12px; +} + +.conversations-activity-item i { + width: 4px; + border-radius: 999px; + background: rgba(108, 123, 107, 0.5); +} + +.conversations-activity-item i.is-primary { + background: #25d366; +} + +.conversations-activity-item strong { + display: block; + margin-bottom: 4px; + font-size: 14px; + line-height: 20px; +} + +.conversations-profile-footer { + border-top: 1px solid rgba(187, 203, 185, 0.95); + background: #fff; +} + +.conversations-profile-footer button { + width: 100%; + justify-content: center; + min-height: 42px; + border-radius: 12px; +} + +@keyframes analyticsSpin { + to { + transform: rotate(360deg); + } +} + +@keyframes analyticsPulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.45; + } +} + +.dashboard-kpi-delta.neutral { + color: var(--muted); +} + +.dashboard-volume-card { + min-height: 400px; + display: flex; + flex-direction: column; +} + +.card-head h3, +.dashboard-funnel-card h3, +.dashboard-alerts-card h3, +.dashboard-webhook-title h3, +.dashboard-table-head h3 { + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 18px; + line-height: 24px; + font-weight: 600; + color: var(--text); +} + +.dashboard-select { + padding: 4px 12px; + border-radius: 8px; + border: 0; + background: #e7f1e4; + font-size: 14px; + font-weight: 700; +} + +.dashboard-line-chart { + position: relative; + flex: 1; + min-height: 300px; +} + +.dashboard-line-axis { + position: absolute; + inset: 0; + display: flex; + justify-content: space-between; + align-items: flex-end; + padding: 0 16px 32px; +} + +.dashboard-axis-column { + position: relative; + width: 1px; + height: 100%; +} + +.dashboard-axis-line { + position: absolute; + left: 50%; + bottom: 24px; + width: 2px; + transform: translateX(-50%); + background: rgba(187, 203, 185, 0.35); +} + +.dashboard-axis-column span { + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--muted); +} + +.dashboard-line-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.dashboard-campaigns-card { + overflow: hidden; + padding: 0; +} + +.dashboard-table-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.dashboard-link-button { + padding: 0; + border-radius: 0; + background: transparent; + color: var(--accent); + box-shadow: none; + font-size: 14px; + font-weight: 700; +} + +.dashboard-table { + width: 100%; + border-collapse: collapse; + font-family: "Inter", "Segoe UI", sans-serif; + font-size: 14px; +} + +.dashboard-table thead { + background: rgba(237, 246, 233, 0.72); +} + +.dashboard-table th, +.dashboard-table td { + padding: 14px 20px; + text-align: left; +} + +.dashboard-table th { + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--muted); +} + +.dashboard-table tbody tr { + border-top: 1px solid rgba(187, 203, 185, 0.3); +} + +.dashboard-table tbody td:first-child { + font-weight: 700; + color: var(--text); +} + +.status-pill.info { + background: rgba(59, 130, 246, 0.12); + color: #3b82f6; +} + +.dashboard-funnel-card { + display: flex; + flex-direction: column; + gap: 24px; +} + +.dashboard-funnel-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.dashboard-funnel-meta { + display: flex; + justify-content: space-between; + margin-bottom: 6px; + font-size: 14px; +} + +.dashboard-funnel-meta span:first-child { + font-weight: 700; + color: var(--text); +} + +.dashboard-funnel-track { + height: 12px; + border-radius: 999px; + background: #e7f1e4; + overflow: hidden; +} + +.dashboard-funnel-fill { + height: 100%; + border-radius: inherit; + background: var(--accent); +} + +.dashboard-webhook-card { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; + border-radius: 12px; + background: #2a332a; + color: #eaf3e6; + box-shadow: 0 12px 28px rgba(21, 30, 22, 0.16); +} + +.dashboard-webhook-card * { + color: inherit; +} + +.dashboard-webhook-head, +.dashboard-endpoint-meta, +.dashboard-webhook-stats div { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.dashboard-webhook-title { + display: flex; + align-items: center; + gap: 8px; +} + +.dashboard-webhook-title .material-symbols-outlined { + color: var(--accent-bright); +} + +.dashboard-live-dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--accent-bright); + box-shadow: 0 0 0 6px rgba(37, 211, 102, 0.18); +} + +.dashboard-endpoint-box { + padding: 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(108, 123, 107, 0.24); +} + +.dashboard-endpoint-meta span { + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.dashboard-endpoint-meta span:last-child { + color: var(--accent-bright); + font-weight: 700; +} + +.dashboard-endpoint-box code { + display: block; + margin-top: 8px; + font-family: "JetBrains Mono", monospace; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dashboard-webhook-stats { + display: flex; + flex-direction: column; + gap: 12px; +} + +.dashboard-outline-button { + width: 100%; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid rgba(187, 203, 185, 0.22); + background: transparent; + color: #eaf3e6; + box-shadow: none; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 700; +} + +.dashboard-alerts-card { + display: flex; + flex-direction: column; + gap: 12px; +} + +.dashboard-alert { + display: flex; + gap: 12px; + padding: 12px; + border-radius: 10px; +} + +.dashboard-alert strong { + display: block; + margin-bottom: 4px; + color: var(--text); +} + +.dashboard-alert p { + margin: 0; + font-size: 12px; + line-height: 1.45; +} + +.dashboard-alert.danger { + background: rgba(255, 218, 214, 0.2); + border-left: 4px solid #ba1a1a; +} + +.dashboard-alert.danger .material-symbols-outlined, +.dashboard-alert.danger strong, +.dashboard-alert.danger p { + color: #93000a; +} + +.dashboard-alert.info { + background: #e7f1e4; + border-left: 4px solid var(--accent); +} + +.dashboard-alert.info .material-symbols-outlined { + color: var(--accent); +} + +@media (max-width: 960px) { + .dashboard-sidebar { + position: static; + width: auto; + height: auto; + overflow: visible; + } + + .dashboard-sidebar-scroll { + overflow: visible; + padding: 0; + } + + .dashboard-main { + margin-left: 0; + } + + .dashboard-overview-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .dashboard-topbar, + .dashboard-topbar-left, + .dashboard-topbar-actions { + flex-direction: column; + align-items: flex-start; + height: auto; + padding-top: 12px; + padding-bottom: 12px; + } + + .dashboard-stat-grid { + grid-template-columns: 1fr; + } + + .dashboard-table { + display: block; + overflow-x: auto; + } +} + +.users-row-button { + width: 100%; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; +} + +.users-row-button:hover { + background: rgba(231, 241, 228, 0.72); +} + +.users-board { + display: flex; + flex-direction: column; + gap: 32px; +} + +.users-page-header { + margin-bottom: 0; +} + +.users-hero-button { + display: inline-flex; + align-items: center; + gap: 10px; + border: 0; + border-radius: 16px; + background: var(--accent); + color: #fff; + padding: 14px 20px; + font-weight: 700; + box-shadow: 0 18px 30px rgba(0, 109, 47, 0.18); + transition: transform 180ms ease, opacity 180ms ease; +} + +.users-hero-button:hover { + opacity: 0.94; + transform: translateY(-2px); +} + +.users-hero-button:disabled { + opacity: 0.7; + transform: none; + cursor: wait; +} + +.users-stats-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 24px; +} + +.users-stat-card { + grid-column: span 4; + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + padding: 24px; + border-radius: 24px; + background: #fff; + border: 1px solid rgba(187, 203, 185, 0.35); + box-shadow: 0 12px 24px rgba(21, 30, 22, 0.04); + position: relative; + overflow: hidden; +} + +.users-stat-card.is-accent { + background: var(--accent); + color: #fff; + border-color: transparent; +} + +.users-stat-content { + position: relative; + z-index: 1; +} + +.users-stat-glow { + position: absolute; + right: -32px; + bottom: -48px; + width: 148px; + height: 148px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + filter: blur(18px); +} + +.users-stat-label { + margin: 0 0 8px; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #64748b; + font-weight: 700; +} + +.users-stat-card.is-accent .users-stat-label, +.users-stat-card.is-accent .users-stat-copy { + color: rgba(255, 255, 255, 0.88); +} + +.users-stat-card h3 { + margin: 0; + font-family: "Plus Jakarta Sans", sans-serif; + font-size: 36px; + line-height: 1; +} + +.users-stat-copy { + margin: 8px 0 0; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: #25d366; +} + +.users-stat-copy.is-warning { + color: #f59e0b; +} + +.users-stat-icon { + width: 56px; + height: 56px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.users-stat-icon .material-symbols-outlined { + font-size: 30px; +} + +.users-stat-icon.tone-primary { + background: rgba(0, 109, 47, 0.1); + color: var(--accent); +} + +.users-stat-icon.tone-secondary { + background: rgba(140, 241, 225, 0.35); + color: #006b5f; +} + +.users-stat-icon.tone-contrast { + position: relative; + z-index: 1; + background: rgba(255, 255, 255, 0.2); + color: #fff; +} + +.users-form-shell { + display: grid; + grid-template-columns: 1fr; +} + +.users-panel-layout { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr); + gap: 24px; +} + +.users-edit-layout { + display: grid; + grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); + gap: 24px; + align-items: start; +} + +.users-form-card { + padding: 28px; + border-radius: 24px; + background: #fff; + border: 1px solid rgba(187, 203, 185, 0.35); + box-shadow: 0 12px 28px rgba(21, 30, 22, 0.05); +} + +.users-form-card.edit { + padding: 36px; +} + +.users-form-head { + display: flex; + justify-content: space-between; + gap: 20px; + margin-bottom: 24px; +} + +.users-form-head.create { + margin-bottom: 32px; +} + +.users-form-head h2 { + margin: 6px 0 8px; + font-family: "Plus Jakarta Sans", sans-serif; + font-size: 28px; + line-height: 1.1; +} + +.users-form-head p { + margin: 0; + color: #64748b; + max-width: 720px; +} + +.users-panel-badge { + align-self: flex-start; + padding: 10px 14px; + border-radius: 999px; + background: #e7f1e4; + color: var(--accent); + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 700; +} + +.users-panel-badge.tone-success { + background: rgba(37, 211, 102, 0.16); + color: #0b7d38; +} + +.users-panel-badge.tone-warning { + background: rgba(245, 158, 11, 0.16); + color: #a16207; +} + +.users-panel-badge.tone-error { + background: rgba(239, 68, 68, 0.14); + color: #b91c1c; +} + +.users-panel-badge.tone-muted { + background: rgba(187, 203, 185, 0.35); + color: #3c4a3d; +} + +.users-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px 24px; +} + +.users-create-section { + padding: 24px; + border-radius: 20px; + background: #fff; + border: 1px solid rgba(187, 203, 185, 0.28); +} + +.users-create-section h3 { + margin: 0 0 24px; + display: inline-flex; + align-items: center; + gap: 10px; + font-family: "Plus Jakarta Sans", sans-serif; + font-size: 18px; +} + +.users-form-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.users-form-field > span { + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #3c4a3d; + font-weight: 700; +} + +.users-form-field input, +.users-form-field select, +.users-filter-field input, +.users-filter-field select { + width: 100%; + min-width: 0; + height: 56px; + padding: 0 18px; + border-radius: 14px; + border: 1px solid rgba(187, 203, 185, 0.9); + background: #ffffff; + color: #1a1c1e; + font-size: 16px; + line-height: 24px; + box-sizing: border-box; + outline: none; + transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease; +} + +.users-form-field input::placeholder, +.users-filter-field input::placeholder { + color: #8a94a6; +} + +.users-form-field input:focus, +.users-form-field select:focus, +.users-filter-field input:focus, +.users-filter-field select:focus { + border-color: #25d366; + box-shadow: 0 0 0 3px rgba(37, 211, 102, 0.14); +} + +.users-form-field input:disabled, +.users-form-field select:disabled { + background: #f8f9fa; + color: #7b8799; +} + +.users-form-note { + margin-top: 20px; + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + border-radius: 14px; + background: #edf6e9; + color: #3c4a3d; + font-size: 14px; +} + +.users-footer-actions { + margin-top: 32px; + padding-top: 24px; + border-top: 1px solid rgba(187, 203, 185, 0.3); + display: flex; + justify-content: flex-end; + gap: 16px; +} + +.users-form-actions { + margin-top: 24px; + justify-content: flex-end; +} + +.users-edit-meta { + margin-top: 20px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.users-edit-meta div { + padding: 16px; + border-radius: 16px; + background: #f8f9fa; + border: 1px solid rgba(187, 203, 185, 0.3); +} + +.users-edit-meta strong { + display: block; + font-family: "Plus Jakarta Sans", sans-serif; + font-size: 18px; + margin-bottom: 6px; +} + +.users-edit-meta span { + color: #64748b; + font-size: 14px; +} + +.users-feedback { + margin: 0; + padding: 14px 16px; + border-radius: 14px; + background: rgba(37, 211, 102, 0.12); + color: #0b7d38; + font-size: 14px; +} + +.users-summary-card { + padding: 24px; + border-radius: 24px; + background: var(--accent); + color: #fff; + box-shadow: 0 18px 36px rgba(0, 109, 47, 0.16); +} + +.users-summary-card h4 { + margin: 0 0 20px; + font-family: "Plus Jakarta Sans", sans-serif; + font-size: 20px; +} + +.users-summary-list { + display: flex; + flex-direction: column; + gap: 18px; +} + +.users-summary-list div { + display: flex; + justify-content: space-between; + gap: 12px; + padding-bottom: 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.2); +} + +.users-summary-list span { + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.74); +} + +.users-summary-list p { + margin: 0; + color: rgba(255, 255, 255, 0.86); + font-size: 14px; + line-height: 1.6; +} + +.users-illustration-card { + overflow: hidden; + border-radius: 24px; + background: #e7f1e4; + border: 1px solid rgba(187, 203, 185, 0.3); +} + +.users-illustration-art { + height: 192px; + background: + radial-gradient(circle at 20% 20%, rgba(37, 211, 102, 0.45), transparent 32%), + radial-gradient(circle at 80% 30%, rgba(0, 107, 95, 0.24), transparent 28%), + linear-gradient(135deg, #dce5d8 0%, #f3fcef 55%, #edf6e9 100%); +} + +.users-illustration-copy { + padding: 20px; +} + +.users-illustration-copy h5 { + margin: 0 0 6px; + font-family: "Plus Jakarta Sans", sans-serif; + font-size: 18px; +} + +.users-illustration-copy p { + margin: 0; + color: #64748b; + font-size: 14px; + line-height: 1.6; +} + +.users-profile-card, +.users-security-card, +.users-danger-card { + padding: 24px; + border-radius: 24px; + background: #fff; + border: 1px solid rgba(187, 203, 185, 0.3); + box-shadow: 0 12px 28px rgba(21, 30, 22, 0.05); +} + +.users-profile-card { + text-align: center; +} + +.users-profile-avatar { + width: 128px; + height: 128px; + margin: 0 auto 16px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(37, 211, 102, 0.16); + color: var(--accent); + font-family: "Plus Jakarta Sans", sans-serif; + font-size: 40px; + font-weight: 800; + border: 4px solid #fff; + box-shadow: 0 0 0 2px rgba(37, 211, 102, 0.28); +} + +.users-profile-card h3 { + margin: 0 0 4px; + font-family: "Plus Jakarta Sans", sans-serif; + font-size: 24px; +} + +.users-profile-card > p { + margin: 0 0 24px; + color: #64748b; +} + +.users-profile-meta { + padding-top: 24px; + border-top: 1px solid rgba(187, 203, 185, 0.3); + display: flex; + flex-direction: column; + gap: 16px; + text-align: left; +} + +.users-profile-meta p { + margin: 0 0 6px; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #64748b; +} + +.users-profile-meta strong { + font-size: 16px; +} + +.users-profile-meta strong.tone-success { + color: #0b7d38; +} + +.users-profile-meta strong.tone-warning { + color: #a16207; +} + +.users-profile-meta strong.tone-error { + color: #b91c1c; +} + +.users-security-card h4, +.users-danger-card h4 { + margin: 0 0 16px; + font-family: "Plus Jakarta Sans", sans-serif; + font-size: 14px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #64748b; +} + +.users-toolbar-button.full { + width: 100%; + justify-content: center; + padding: 14px 16px; + color: var(--accent); +} + +.users-danger-card { + display: flex; + gap: 16px; + background: rgba(255, 218, 214, 0.16); + border-color: rgba(186, 26, 26, 0.18); +} + +.users-danger-icon { + width: 40px; + height: 40px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255, 218, 214, 0.9); + color: #93000a; +} + +.users-danger-card p { + margin: 0 0 16px; + color: #64748b; +} + +.users-danger-card button { + padding: 10px 16px; + border-radius: 12px; + border: 0; + background: #ba1a1a; + color: #fff; + font-weight: 700; +} + +.users-danger-card button:disabled { + opacity: 0.6; +} + +.users-table-shell { + border-radius: 24px; + overflow: hidden; + background: #fff; + border: 1px solid rgba(187, 203, 185, 0.35); + box-shadow: 0 12px 28px rgba(21, 30, 22, 0.05); +} + +.users-table-toolbar { + padding: 20px 24px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + border-bottom: 1px solid rgba(187, 203, 185, 0.3); +} + +.users-table-toolbar h4 { + margin: 0; + font-family: "Plus Jakarta Sans", sans-serif; + font-size: 18px; +} + +.users-toolbar-actions { + display: flex; + gap: 12px; +} + +.users-toolbar-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: 12px; + border: 1px solid rgba(187, 203, 185, 0.35); + background: transparent; + color: #3c4a3d; +} + +.users-toolbar-button.large { + padding: 18px 28px; + border-radius: 22px; + font-size: 14px; + font-weight: 700; +} + +.users-filters-panel { + padding: 20px 24px; + display: grid; + grid-template-columns: minmax(220px, 2fr) repeat(2, minmax(180px, 1fr)) auto; + gap: 16px; + border-bottom: 1px solid rgba(187, 203, 185, 0.3); + background: #fcfdfb; +} + +.users-filter-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.users-filter-field span { + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #64748b; + font-weight: 700; +} + +.users-filter-actions { + display: flex; + align-items: flex-end; +} + +.users-table-wrap { + overflow-x: auto; +} + +.users-table { + width: 100%; + border-collapse: collapse; +} + +.users-table th, +.users-table td { + padding: 18px 24px; + text-align: left; +} + +.users-table thead th { + background: rgba(237, 246, 233, 0.65); + color: #3c4a3d; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.users-table tbody tr { + border-top: 1px solid rgba(187, 203, 185, 0.22); +} + +.users-table tbody tr:hover { + background: rgba(248, 249, 250, 0.8); +} + +.users-identity { + display: flex; + align-items: center; + gap: 14px; +} + +.users-avatar { + width: 40px; + height: 40px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; +} + +.users-avatar.tone-admin { + background: rgba(37, 211, 102, 0.16); + color: var(--accent); +} + +.users-avatar.tone-editor { + background: rgba(140, 241, 225, 0.3); + color: #006b5f; +} + +.users-avatar.tone-agent { + background: rgba(220, 229, 216, 0.8); + color: #3c4a3d; +} + +.users-identity p { + margin: 0 0 2px; + font-weight: 700; +} + +.users-identity span, +.users-muted { + color: #64748b; + font-size: 14px; +} + +.users-role-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 12px; + border-radius: 999px; + font-size: 11px; + line-height: 14px; + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 700; + border: 1px solid transparent; +} + +.users-role-pill.tone-admin { + background: rgba(255, 160, 126, 0.18); + color: #78351b; + border-color: rgba(255, 160, 126, 0.25); +} + +.users-role-pill.tone-editor { + background: rgba(140, 241, 225, 0.32); + color: #006f64; + border-color: rgba(140, 241, 225, 0.4); +} + +.users-role-pill.tone-agent { + background: rgba(220, 229, 216, 0.65); + color: #3c4a3d; + border-color: rgba(187, 203, 185, 0.38); +} + +.users-status { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.users-status-dot { + width: 8px; + height: 8px; + border-radius: 999px; +} + +.users-status-dot.tone-success { + background: #25d366; +} + +.users-status-dot.tone-warning { + background: #f59e0b; +} + +.users-status-dot.tone-error { + background: #ef4444; +} + +.users-status-dot.tone-muted { + background: #94a3b8; +} + +.users-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.users-actions button { + width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 10px; + background: transparent; + color: #64748b; +} + +.users-actions button:hover { + background: rgba(0, 109, 47, 0.06); + color: var(--accent); +} + +.users-empty-state { + padding: 48px 24px; + text-align: center; + color: #64748b; +} + +.users-table .align-right { + text-align: right; +} + +.users-pagination { + padding: 18px 24px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + border-top: 1px solid rgba(187, 203, 185, 0.3); + background: #fcfdfb; +} + +.users-pagination p { + margin: 0; + font-size: 14px; + color: #64748b; +} + +.users-pagination-buttons { + display: flex; + gap: 6px; +} + +.users-pagination-buttons button { + width: 40px; + height: 40px; + border-radius: 10px; + border: 1px solid rgba(187, 203, 185, 0.35); + background: transparent; + color: #3c4a3d; +} + +.users-pagination-buttons button.is-active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.users-loading-bar { + height: 3px; + background: linear-gradient(90deg, rgba(0, 109, 47, 0.08), var(--accent), rgba(0, 109, 47, 0.08)); + background-size: 200% 100%; + animation: users-loading 1.1s linear infinite; +} + +@keyframes users-loading { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} + +.users-role-overview { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + gap: 24px; + align-items: start; +} + +.users-role-overview h5 { + margin: 0 0 8px; + font-family: "Plus Jakarta Sans", sans-serif; + font-size: 18px; +} + +.users-role-overview p { + margin: 0; + color: #64748b; +} + +.users-role-cards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} + +.users-role-card { + padding: 18px; + border-radius: 18px; + background: #fff; + border: 1px solid rgba(187, 203, 185, 0.3); +} + +.users-role-card-head { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 700; +} + +.users-role-card-head.tone-admin { + color: #78351b; +} + +.users-role-card-head.tone-editor { + color: #006f64; +} + +.users-role-card-head.tone-agent { + color: #3c4a3d; +} + +@media (max-width: 1100px) { + .users-stat-card { + grid-column: span 12; + } + + .users-panel-layout, + .users-edit-layout { + grid-template-columns: 1fr; + } + + .users-role-overview { + grid-template-columns: 1fr; + } + + .users-role-cards { + grid-template-columns: 1fr; + } + + .users-filters-panel { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 720px) { + .users-form-grid, + .users-edit-meta { + grid-template-columns: 1fr; + } + + .users-form-head, + .users-table-toolbar, + .users-pagination { + flex-direction: column; + align-items: flex-start; + } + + .users-toolbar-actions, + .users-pagination-buttons { + flex-wrap: wrap; + } + + .users-filters-panel { + grid-template-columns: 1fr; + } + + .users-footer-actions { + flex-direction: column; + align-items: stretch; + } +} + +.invite-page { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 24px; + background: + radial-gradient(circle at top, rgba(102, 255, 142, 0.2), transparent 35%), + #edf6e9; +} + +.invite-brand { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 32px; + color: var(--accent); + font-family: "Plus Jakarta Sans", sans-serif; + font-size: 24px; + font-weight: 800; +} + +.invite-brand-mark { + width: 44px; + height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: var(--accent); + color: #fff; +} + +.invite-card { + width: 100%; + max-width: 480px; + padding: 40px; + border-radius: 20px; + background: #fff; + border: 1px solid rgba(187, 203, 185, 0.8); + box-shadow: 0 24px 60px rgba(26, 28, 30, 0.08); +} + +.invite-card-head { + text-align: center; + margin-bottom: 32px; +} + +.invite-card-head h1 { + margin: 0 0 12px; + font-family: "Plus Jakarta Sans", sans-serif; + font-size: 30px; + line-height: 1.1; +} + +.invite-card-head p { + margin: 0; + color: #64748b; +} + +.invite-form { + display: flex; + flex-direction: column; + gap: 24px; +} + +.invite-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.invite-field input, +.invite-field textarea { + width: 100%; + border: 1px solid rgba(187, 203, 185, 0.8); + border-radius: 14px; + background: #fff; + color: var(--text); + padding: 14px 16px; + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +.invite-field input:focus, +.invite-field textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 4px rgba(37, 211, 102, 0.12); +} + +.invite-field > span, +.invite-strength-head span { + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #3c4a3d; + font-weight: 700; +} + +.invite-password-wrap { + position: relative; +} + +.invite-password-wrap input { + padding-right: 52px; +} + +.invite-password-wrap button { + position: absolute; + top: 50%; + right: 12px; + transform: translateY(-50%); + border: 0; + background: transparent; + color: #64748b; + cursor: pointer; +} + +.invite-strength { + display: flex; + flex-direction: column; + gap: 10px; +} + +.invite-strength-head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.invite-strength-head strong { + color: var(--accent); + font-size: 12px; + line-height: 16px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.invite-strength-bars { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; +} + +.invite-strength-bars span { + height: 6px; + border-radius: 999px; + background: #dce5d8; +} + +.invite-strength-bars span.is-active { + background: var(--accent); +} + +.invite-checklist { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border-radius: 12px; + background: #edf6e9; +} + +.invite-checklist div { + display: flex; + align-items: center; + gap: 10px; + color: #64748b; + font-size: 14px; +} + +.invite-checklist div.is-done { + color: #151e16; +} + +.invite-checklist .material-symbols-outlined { + font-size: 18px; +} + +.invite-checklist div.is-done .material-symbols-outlined { + color: #25d366; + font-variation-settings: "FILL" 1; +} + +.invite-submit-button { + width: 100%; + height: 48px; + border: 0; + border-radius: 10px; + background: var(--accent); + color: #fff; + font-weight: 700; + cursor: pointer; + transition: transform 180ms ease, opacity 180ms ease; +} + +.invite-submit-button:hover { + opacity: 0.92; +} + +.invite-submit-button:active { + transform: scale(0.985); +} + +.invite-submit-button:disabled { + cursor: wait; + opacity: 0.7; +} + +.invite-footer { + margin-top: 32px; + padding-top: 24px; + border-top: 1px solid rgba(187, 203, 185, 0.8); + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + color: #64748b; + font-size: 14px; +} + +.invite-footer a { + color: var(--accent); + font-weight: 700; +} + +.auth-public-copy { + margin-bottom: 24px; +} + +.auth-public-meta { + font-size: 0.9rem; + color: var(--muted-soft); +} + +.auth-inline-footer { + margin-top: 24px; +} + +.security-session-card { + display: grid; + gap: 24px; +} + +.security-session-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.security-session-head h2 { + margin: 6px 0 8px; + font-size: 1.35rem; +} + +.security-session-badge { + display: inline-flex; + align-items: center; + padding: 8px 12px; + border-radius: 999px; + background: rgba(37, 211, 102, 0.12); + color: var(--accent-dark); + font-size: 0.76rem; + line-height: 1rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 700; +} + +.security-session-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.security-session-item { + display: grid; + gap: 6px; + padding: 16px 18px; + border-radius: 18px; + border: 1px solid rgba(187, 203, 185, 0.45); + background: rgba(237, 246, 233, 0.55); +} + +.security-session-item strong { + font-size: 0.76rem; + line-height: 1rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.security-session-item span { + font-size: 1rem; + font-weight: 700; + color: var(--text); +} + +.security-session-item small { + color: var(--muted-soft); + font-size: 0.9rem; +} + +.security-session-actions { + display: grid; + gap: 12px; +} + +.security-session-actions form { + max-width: 320px; +} + +.campaigns-page { + display: grid; + gap: 32px; +} + +.campaigns-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; +} + +.campaigns-title { + margin: 0; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: clamp(2rem, 3vw, 3rem); + line-height: 1.05; + letter-spacing: -0.03em; + color: #1a1c1e; +} + +.campaigns-copy { + margin: 10px 0 0; + max-width: 720px; + color: #64748b; + font-size: 16px; + line-height: 1.6; +} + +.campaigns-primary-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 52px; + padding: 0 24px; + border: 0; + border-radius: 16px; + background: var(--accent); + color: #fff; + font-weight: 700; + box-shadow: 0 16px 36px rgba(0, 109, 47, 0.16); + cursor: pointer; + transition: transform 180ms ease, opacity 180ms ease; +} + +.campaigns-primary-button:hover { + opacity: 0.92; +} + +.campaigns-primary-button:active { + transform: scale(0.98); +} + +.campaigns-primary-button .material-symbols-outlined { + font-size: 20px; +} + +.campaigns-stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 24px; +} + +.campaigns-create-modal-backdrop { + position: fixed; + inset: 0; + z-index: 80; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: rgba(21, 30, 22, 0.35); + backdrop-filter: blur(6px); +} + +.campaigns-create-modal { + width: min(980px, 100%); + max-height: calc(100vh - 64px); + overflow: auto; + border-radius: 24px; + background: #fff; + border: 1px solid rgba(187, 203, 185, 0.5); + box-shadow: 0 32px 72px rgba(12, 18, 16, 0.18); +} + +.campaigns-create-modal-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 24px 24px 0; +} + +.campaigns-create-modal-head p { + margin: 0; + color: #64748b; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.campaigns-create-modal-head h2 { + margin: 6px 0 0; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 28px; + line-height: 1.2; + color: #1a1c1e; +} + +.campaigns-create-modal-head button { + width: 40px; + height: 40px; + border: 0; + border-radius: 12px; + background: #edf6e9; + color: #475569; +} + +.campaigns-create-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px 20px; + padding: 24px; +} + +.campaigns-create-form label { + display: grid; + gap: 8px; +} + +.campaigns-create-form label.is-wide { + grid-column: 1 / -1; +} + +.campaigns-create-form label span { + color: #4d5c50; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.campaigns-create-form input, +.campaigns-create-form select, +.campaigns-create-form textarea { + width: 100%; + min-height: 50px; + border-radius: 14px; + border: 1px solid rgba(187, 203, 185, 0.72); + background: #f9fcf7; + padding: 0 16px; + color: #1a1c1e; + font-size: 15px; + outline: none; +} + +.campaigns-create-form textarea { + min-height: 120px; + padding: 14px 16px; + resize: vertical; +} + +.campaigns-create-form input:focus, +.campaigns-create-form select:focus, +.campaigns-create-form textarea:focus { + border-color: rgba(0, 109, 47, 0.45); + box-shadow: 0 0 0 3px rgba(0, 109, 47, 0.12); +} + +.campaigns-create-error { + grid-column: 1 / -1; + margin: 0; + color: #dc2626; + font-size: 14px; + font-weight: 600; +} + +.campaigns-create-actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + gap: 12px; + padding-top: 8px; +} + +.campaigns-create-cancel { + min-height: 50px; + padding: 0 20px; + border-radius: 14px; + border: 1px solid rgba(187, 203, 185, 0.8); + background: #fff; + color: #1a1c1e; + font-weight: 700; +} + +.campaigns-stat-card { + min-height: 152px; + padding: 20px; + border: 1px solid rgba(187, 203, 185, 0.45); + border-radius: 20px; + background: #fff; + box-shadow: 0 12px 30px rgba(12, 18, 16, 0.04); + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.campaigns-stat-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.campaigns-stat-head > .material-symbols-outlined { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 14px; + background: #e7f1e4; + color: var(--accent); + font-size: 22px; +} + +.campaigns-stat-head > .material-symbols-outlined.is-secondary { + color: #006b5f; +} + +.campaigns-stat-head > .material-symbols-outlined.is-warning { + color: #f59e0b; +} + +.campaigns-stat-head > .material-symbols-outlined.is-error { + color: #ef4444; +} + +.campaigns-stat-delta { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 12px; + font-weight: 700; +} + +.campaigns-stat-delta.is-positive { + color: #16a34a; +} + +.campaigns-stat-delta .material-symbols-outlined { + font-size: 16px; +} + +.campaigns-stat-card p { + margin: 0; + color: #64748b; + font-size: 14px; + font-weight: 600; +} + +.campaigns-stat-card strong { + display: block; + margin-top: 6px; + color: var(--accent); + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 32px; + line-height: 1.1; +} + +.campaigns-stat-card strong.is-secondary { + color: #006b5f; +} + +.campaigns-table-card { + overflow: hidden; + border: 1px solid rgba(187, 203, 185, 0.5); + border-radius: 24px; + background: #fff; + box-shadow: 0 18px 40px rgba(12, 18, 16, 0.05); +} + +.campaigns-table-toolbar { + padding: 24px; + border-bottom: 1px solid rgba(187, 203, 185, 0.45); + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +.campaigns-filter-tabs { + display: flex; + align-items: center; + gap: 10px; + overflow-x: auto; + padding-bottom: 4px; +} + +.campaigns-filter-pill { + border: 0; + border-radius: 999px; + padding: 12px 16px; + background: transparent; + color: #4d5c50; + font-size: 14px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + transition: background 180ms ease, color 180ms ease; +} + +.campaigns-filter-pill:hover { + background: #edf6e9; +} + +.campaigns-filter-pill.is-active { + background: rgba(37, 211, 102, 0.18); + color: #005523; + font-weight: 700; +} + +.campaigns-filter-tabs .campaigns-filter-pill:last-child:not(.is-active) { + color: #ef4444; +} + +.campaigns-table-controls { + display: flex; + align-items: center; + gap: 12px; +} + +.campaigns-search-field, +.campaigns-sort-field { + position: relative; + display: flex; + align-items: center; +} + +.campaigns-search-field .material-symbols-outlined, +.campaigns-sort-field .material-symbols-outlined { + position: absolute; + left: 12px; + color: #64748b; + opacity: 0.72; + font-size: 18px; + pointer-events: none; +} + +.campaigns-search-field input, +.campaigns-sort-field select { + min-height: 44px; + border: 0; + border-radius: 12px; + background: #edf6e9; + color: #1a1c1e; + font-size: 14px; + outline: none; +} + +.campaigns-search-field input { + width: 260px; + padding: 0 14px 0 40px; +} + +.campaigns-sort-field select { + min-width: 210px; + padding: 0 40px 0 40px; + appearance: none; + cursor: pointer; +} + +.campaigns-search-field input:focus, +.campaigns-sort-field select:focus { + box-shadow: 0 0 0 2px rgba(0, 109, 47, 0.18); +} + +.campaigns-table-wrap { + overflow-x: auto; +} + +.campaigns-table { + width: 100%; + min-width: 920px; + border-collapse: collapse; +} + +.campaigns-table thead tr { + background: #edf6e9; +} + +.campaigns-table th, +.campaigns-table td { + padding: 18px 24px; + text-align: left; + vertical-align: middle; +} + +.campaigns-table th { + color: #5f6f63; + font-size: 12px; + line-height: 16px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.campaigns-table tbody tr { + border-top: 1px solid rgba(187, 203, 185, 0.36); + transition: background 180ms ease; +} + +.campaigns-table tbody tr:hover { + background: rgba(243, 252, 239, 0.68); +} + +.campaigns-table th.is-right, +.campaigns-table td.is-right { + text-align: right; +} + +.campaigns-name-link { + display: inline-flex; + flex-direction: column; + gap: 4px; + color: inherit; + text-decoration: none; +} + +.campaigns-name-link span { + font-size: 16px; + font-weight: 700; + color: #151e16; +} + +.campaigns-name-link small, +.campaigns-audience-cell small, +.campaigns-date-cell small { + color: #64748b; + font-size: 11px; + line-height: 16px; +} + +.campaigns-audience-cell { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.campaigns-audience-cell .material-symbols-outlined { + color: #64748b; + opacity: 0.64; + font-size: 18px; +} + +.campaigns-audience-cell > div, +.campaigns-date-cell { + display: inline-flex; + flex-direction: column; + gap: 4px; +} + +.campaigns-audience-cell span, +.campaigns-date-cell span { + color: #151e16; + font-size: 14px; +} + +.campaigns-date-cell.is-scheduled span { + color: #2563eb; + font-weight: 600; +} + +.campaign-status-pill { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 32px; + padding: 0 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.campaign-status-pill.is-sent { + background: rgba(37, 211, 102, 0.16); + color: #0f7a39; +} + +.campaign-status-pill.is-scheduled { + background: rgba(59, 130, 246, 0.12); + border: 1px solid rgba(59, 130, 246, 0.2); + color: #2563eb; +} + +.campaign-status-pill.is-draft { + background: #e7f1e4; + color: #526458; +} + +.campaign-status-pill.is-failed { + background: rgba(239, 68, 68, 0.1); + color: #dc2626; +} + +.campaigns-status-dot { + width: 7px; + height: 7px; + border-radius: 999px; + background: currentColor; +} + +.campaign-status-pill.is-scheduled .campaigns-status-dot { + animation: campaigns-pulse 1.4s ease-in-out infinite; +} + +.campaign-status-pill .material-symbols-outlined { + font-size: 14px; +} + +.campaigns-pending-text { + color: #64748b; + font-size: 14px; + font-style: italic; +} + +.campaigns-delivery-cell { + display: inline-flex; + align-items: center; + gap: 12px; +} + +.campaigns-progress-track { + width: 64px; + height: 6px; + overflow: hidden; + border-radius: 999px; + background: #e7f1e4; +} + +.campaigns-progress-bar { + display: block; + height: 100%; + border-radius: inherit; + background: #25d366; +} + +.campaigns-progress-bar.is-failed { + background: #ef4444; +} + +.campaigns-delivery-cell strong { + color: #151e16; + font-size: 14px; +} + +.campaigns-delivery-cell strong.is-error { + color: #dc2626; +} + +.campaigns-action-button { + width: 40px; + height: 40px; + border: 0; + border-radius: 12px; + background: transparent; + color: #5f6f63; + cursor: pointer; + transition: background 180ms ease, color 180ms ease; +} + +.campaigns-action-button:hover { + background: #edf6e9; +} + +.campaigns-action-button.is-error { + color: #dc2626; +} + +.campaigns-pagination { + padding: 18px 24px; + border-top: 1px solid rgba(187, 203, 185, 0.45); + background: #fff; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} + +.campaigns-pagination p { + margin: 0; + color: #64748b; + font-size: 14px; +} + +.campaigns-pagination strong { + color: #151e16; +} + +.campaigns-pagination-buttons { + display: flex; + align-items: center; + gap: 4px; +} + +.campaigns-pagination-buttons button, +.campaigns-pagination-buttons span { + width: 34px; + height: 34px; + border: 0; + border-radius: 10px; + background: transparent; + color: #526458; + font-size: 14px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.campaigns-pagination-buttons button { + cursor: pointer; + transition: background 180ms ease, color 180ms ease; +} + +.campaigns-pagination-buttons button:hover:not(:disabled):not(.is-active) { + background: #edf6e9; +} + +.campaigns-pagination-buttons button.is-active { + background: var(--accent); + color: #fff; + font-weight: 700; +} + +.campaigns-pagination-buttons button:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.campaigns-insight-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); + gap: 24px; +} + +.campaigns-insight-card, +.campaigns-health-card { + position: relative; + overflow: hidden; + border-radius: 24px; + box-shadow: 0 18px 40px rgba(12, 18, 16, 0.05); +} + +.campaigns-insight-card { + border: 1px solid rgba(187, 203, 185, 0.5); + background: #fff; +} + +.campaigns-insight-glow { + position: absolute; + right: -40px; + bottom: -56px; + width: 220px; + height: 220px; + border-radius: 999px; + background: rgba(37, 211, 102, 0.16); + filter: blur(22px); +} + +.campaigns-insight-body { + position: relative; + z-index: 1; + padding: 24px; +} + +.campaigns-insight-body h2, +.campaigns-health-card h2 { + margin: 0; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 18px; + line-height: 1.3; + color: #151e16; +} + +.campaigns-insight-body p, +.campaigns-health-card p { + margin: 12px 0 0; + color: #64748b; + font-size: 16px; + line-height: 1.7; +} + +.campaigns-insight-body p strong { + color: var(--accent); +} + +.campaigns-insight-body p strong.is-success { + color: #16a34a; +} + +.campaigns-insight-body button { + margin-top: 18px; + min-height: 42px; + padding: 0 16px; + border: 0; + border-radius: 12px; + background: #e7f1e4; + color: #1a1c1e; + font-weight: 700; + cursor: pointer; +} + +.campaigns-health-card { + padding: 24px; + background: var(--accent); + color: #fff; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.campaigns-health-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.campaigns-health-head .material-symbols-outlined { + font-size: 24px; +} + +.campaigns-health-card h2, +.campaigns-health-card p { + color: inherit; +} + +.campaigns-health-card strong { + display: block; + margin-top: 28px; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 42px; + line-height: 1; +} + +@keyframes campaigns-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +@media (max-width: 1120px) { + .campaigns-stats-grid, + .campaigns-insight-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 900px) { + .campaigns-header, + .campaigns-table-toolbar, + .campaigns-pagination { + flex-direction: column; + align-items: stretch; + } + + .campaigns-table-controls { + flex-direction: column; + align-items: stretch; + } + + .campaigns-search-field input, + .campaigns-sort-field select { + width: 100%; + } + + .campaigns-create-form { + grid-template-columns: 1fr; + } +} + +@media (max-width: 720px) { + .campaigns-stats-grid, + .campaigns-insight-grid { + grid-template-columns: 1fr; + } +} + +.campaign-detail-page { + display: grid; + gap: 32px; +} + +.campaign-detail-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; +} + +.campaign-detail-breadcrumb { + display: flex; + align-items: center; + gap: 8px; + color: #64748b; + font-size: 14px; +} + +.campaign-detail-breadcrumb a { + color: inherit; + text-decoration: none; +} + +.campaign-detail-breadcrumb span:last-child { + color: var(--accent); + font-weight: 600; +} + +.campaign-detail-heading-row { + display: flex; + align-items: center; + gap: 14px; + margin-top: 8px; +} + +.campaign-detail-heading-row h1 { + margin: 0; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: clamp(2rem, 3vw, 3rem); + line-height: 1.05; + letter-spacing: -0.03em; + color: #1a1c1e; +} + +.campaign-detail-header p { + margin: 10px 0 0; + color: #64748b; + font-size: 16px; +} + +.campaign-detail-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 0 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.campaign-detail-badge.is-sent { + background: rgba(37, 211, 102, 0.18); + color: #0f7a39; +} + +.campaign-detail-badge.is-scheduled { + background: rgba(59, 130, 246, 0.12); + color: #2563eb; +} + +.campaign-detail-badge.is-draft { + background: #e7f1e4; + color: #4d5c50; +} + +.campaign-detail-badge.is-failed { + background: rgba(239, 68, 68, 0.12); + color: #dc2626; +} + +.campaign-detail-header-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.campaign-detail-header-actions a { + text-decoration: none; +} + +.campaign-detail-header-actions button:disabled, +.campaign-detail-header-actions a:disabled, +.campaign-detail-primary-button:disabled, +.campaign-detail-secondary-button:disabled, +.campaign-detail-danger-button:disabled { + cursor: wait; + opacity: 0.7; +} + +.campaign-detail-primary-button, +.campaign-detail-secondary-button { + min-height: 46px; + padding: 0 18px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-weight: 700; + cursor: pointer; +} + +.campaign-detail-secondary-button { + border: 1px solid rgba(187, 203, 185, 0.7); + background: #fff; + color: #475569; +} + +.campaign-detail-primary-button { + border: 0; + background: var(--accent); + color: #fff; + box-shadow: 0 12px 28px rgba(0, 109, 47, 0.18); +} + +.campaign-detail-danger-button { + min-height: 46px; + padding: 0 18px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 0; + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; + font-weight: 700; + cursor: pointer; +} + +.campaign-detail-inline-tools { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; + margin-top: 16px; +} + +.campaign-detail-schedule-field { + display: grid; + gap: 8px; + min-width: min(100%, 320px); +} + +.campaign-detail-schedule-field span, +.campaign-detail-field span { + color: #3c4a3d; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.campaign-detail-schedule-field input, +.campaign-detail-field input, +.campaign-detail-field select, +.campaign-detail-field textarea { + width: 100%; + min-height: 46px; + border-radius: 12px; + border: 1px solid rgba(187, 203, 185, 0.8); + background: #fff; + padding: 0 14px; + color: #1a1c1e; +} + +.campaign-detail-field textarea { + min-height: 116px; + padding: 14px; + resize: vertical; +} + +.campaign-detail-inline-message { + margin: 14px 0 0; + color: #006d2f; + font-size: 14px; + font-weight: 600; +} + +.campaign-detail-edit-panel { + margin-top: 20px; + padding: 20px; + border-radius: 20px; + border: 1px solid rgba(187, 203, 185, 0.7); + background: #fff; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); +} + +.campaign-detail-edit-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.campaign-detail-field { + display: grid; + gap: 8px; +} + +.campaign-detail-field.is-full { + grid-column: 1 / -1; +} + +.campaign-detail-edit-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 18px; +} + +.campaign-detail-kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 24px; +} + +.campaign-detail-kpi-card, +.campaign-detail-card { + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.5); + background: #fff; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); +} + +.campaign-detail-kpi-card { + padding: 20px; +} + +.campaign-detail-kpi-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; +} + +.campaign-detail-kpi-head > .material-symbols-outlined { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 12px; + background: #e7f1e4; + color: var(--accent); +} + +.campaign-detail-kpi-head > .material-symbols-outlined.is-secondary { + background: rgba(140, 241, 225, 0.3); + color: #006b5f; +} + +.campaign-detail-kpi-head > .material-symbols-outlined.is-tertiary { + background: rgba(255, 160, 126, 0.22); + color: #93492e; +} + +.campaign-detail-kpi-head > .material-symbols-outlined.is-error { + background: rgba(255, 218, 214, 0.5); + color: #ba1a1a; +} + +.campaign-detail-kpi-head .is-positive, +.campaign-detail-kpi-head .is-warning, +.campaign-detail-kpi-head .is-danger { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 12px; + font-weight: 700; +} + +.campaign-detail-kpi-head .is-positive { + color: #16a34a; +} + +.campaign-detail-kpi-head .is-warning { + color: #f59e0b; +} + +.campaign-detail-kpi-head .is-danger { + color: #ef4444; +} + +.campaign-detail-kpi-card p { + margin: 0 0 6px; + color: #64748b; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.campaign-detail-kpi-card strong { + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 32px; + line-height: 1.1; + color: #1a1c1e; +} + +.campaign-detail-main-grid, +.campaign-detail-bottom-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 32px; +} + +.campaign-detail-timeline-card { + grid-column: span 8; + padding: 20px; +} + +.campaign-detail-preview-card { + grid-column: span 4; + padding: 20px; +} + +.campaign-detail-card-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 24px; +} + +.campaign-detail-card-head h2, +.campaign-detail-preview-card h2, +.campaign-detail-recipients-card h2, +.campaign-detail-side-stack h2 { + margin: 0; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 18px; + line-height: 1.3; + color: #1a1c1e; +} + +.campaign-detail-card-head select { + min-height: 36px; + padding: 0 12px; + border-radius: 10px; + border: 1px solid rgba(187, 203, 185, 0.7); + background: #edf6e9; +} + +.campaign-detail-timeline-bars { + min-height: 300px; + display: flex; + align-items: flex-end; + gap: 8px; +} + +.campaign-detail-timeline-column { + flex: 1; + height: 300px; + display: flex; + align-items: flex-end; +} + +.campaign-detail-timeline-bar { + width: 100%; + min-height: 12px; + border-radius: 14px 14px 0 0; + background: rgba(0, 109, 47, 0.1); +} + +.campaign-detail-timeline-bar.is-highlight { + background: var(--accent); + box-shadow: 0 16px 32px rgba(0, 109, 47, 0.2); +} + +.campaign-detail-timeline-labels { + display: flex; + justify-content: space-between; + margin-top: 16px; + color: #64748b; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.05em; +} + +.campaign-preview-phone { + max-width: 320px; + margin: 0 auto; + padding: 16px; + border-radius: 18px; + background: #e7fedc; + position: relative; + overflow: hidden; +} + +.campaign-preview-phone img { + width: 100%; + height: 128px; + object-fit: cover; + border-radius: 12px; + margin-bottom: 12px; +} + +.campaign-preview-title, +.campaign-preview-copy { + margin: 0; + color: #151e16; +} + +.campaign-preview-title { + font-weight: 700; +} + +.campaign-preview-copy { + margin-top: 6px; + line-height: 1.6; + font-size: 14px; +} + +.campaign-preview-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin: 14px 0 16px; + color: #64748b; + font-size: 11px; +} + +.campaign-preview-buttons { + display: grid; + gap: 8px; + border-top: 1px solid rgba(0, 0, 0, 0.08); + padding-top: 10px; +} + +.campaign-preview-buttons button { + min-height: 40px; + border: 0; + border-radius: 10px; + background: #fff; + color: var(--accent); + font-weight: 700; +} + +.campaign-preview-foot { + margin-top: 24px; + display: grid; + gap: 12px; +} + +.campaign-preview-foot div { + display: flex; + justify-content: space-between; + gap: 16px; + font-size: 14px; +} + +.campaign-preview-foot span { + color: #64748b; +} + +.campaign-preview-foot strong { + color: #1a1c1e; +} + +.campaign-detail-recipients-card { + grid-column: span 9; + overflow: hidden; +} + +.campaign-detail-side-stack { + grid-column: span 3; + display: grid; + gap: 32px; + align-content: start; +} + +.campaign-detail-icon-actions { + display: flex; + gap: 8px; +} + +.campaign-detail-icon-actions button { + width: 36px; + height: 36px; + border: 0; + border-radius: 10px; + background: transparent; + color: #5f6f63; +} + +.campaign-detail-table-wrap { + overflow-x: auto; +} + +.campaign-detail-table { + width: 100%; + border-collapse: collapse; +} + +.campaign-detail-table thead { + background: #edf6e9; +} + +.campaign-detail-table th, +.campaign-detail-table td { + padding: 14px 24px; + text-align: left; + border-bottom: 1px solid rgba(226, 232, 240, 0.8); +} + +.campaign-detail-table th { + color: #64748b; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.campaign-detail-table tbody tr:hover { + background: rgba(237, 246, 233, 0.65); +} + +.campaign-detail-mono { + font-family: "JetBrains Mono", monospace; + font-size: 14px; +} + +.campaign-recipient-status { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; +} + +.campaign-recipient-status.is-read { + color: var(--accent); +} + +.campaign-recipient-status.is-delivered { + color: #6c7b6b; +} + +.campaign-recipient-status.is-failed { + color: #ef4444; +} + +.campaign-recipient-status .material-symbols-outlined { + font-size: 16px; +} + +.campaign-detail-error-pill { + display: inline-flex; + padding: 4px 8px; + border-radius: 999px; + background: rgba(255, 218, 214, 0.35); + color: #ba1a1a; + font-size: 11px; + font-weight: 600; +} + +.campaign-detail-table-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 24px; +} + +.campaign-detail-table-footer p { + margin: 0; + color: #64748b; + font-size: 14px; +} + +.campaign-detail-pagination { + display: flex; + align-items: center; + gap: 8px; +} + +.campaign-detail-pagination a { + width: 36px; + height: 36px; + border-radius: 10px; + border: 1px solid rgba(226, 232, 240, 0.9); + color: #1a1c1e; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.campaign-detail-pagination a.is-disabled { + pointer-events: none; + opacity: 0.45; +} + +.campaign-detail-side-stack > .campaign-detail-card { + padding: 20px; +} + +.campaign-detail-device-list { + display: grid; + gap: 20px; +} + +.campaign-detail-device-row { + display: grid; + gap: 8px; +} + +.campaign-detail-device-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 14px; +} + +.campaign-detail-device-head span { + display: inline-flex; + align-items: center; + gap: 8px; + color: #4d5c50; +} + +.campaign-detail-device-track { + width: 100%; + height: 8px; + overflow: hidden; + border-radius: 999px; + background: #e7f1e4; +} + +.campaign-detail-device-track span { + display: block; + height: 100%; + border-radius: inherit; +} + +.campaign-detail-device-track span.is-android { + background: var(--accent); +} + +.campaign-detail-device-track span.is-ios { + background: #006b5f; +} + +.campaign-detail-device-track span.is-web { + background: #6c7b6b; +} + +.campaign-detail-insight-card { + position: relative; + overflow: hidden; + padding: 20px; + border-radius: 20px; + background: var(--accent); + color: #fff; + box-shadow: 0 16px 34px rgba(0, 109, 47, 0.22); +} + +.campaign-detail-insight-icon { + position: absolute; + right: -18px; + bottom: -18px; + opacity: 0.1; +} + +.campaign-detail-insight-icon .material-symbols-outlined { + font-size: 96px; +} + +.campaign-detail-insight-card h3 { + margin: 0; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 18px; +} + +.campaign-detail-insight-card p { + margin: 12px 0 18px; + color: rgba(255, 255, 255, 0.82); + font-size: 14px; + line-height: 1.7; + position: relative; + z-index: 1; +} + +.campaign-detail-insight-card button { + width: 100%; + min-height: 42px; + border: 0; + border-radius: 12px; + background: #fff; + color: var(--accent); + font-weight: 700; + position: relative; + z-index: 1; +} + +@media (max-width: 1180px) { + .campaign-detail-kpi-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .campaign-detail-timeline-card, + .campaign-detail-preview-card, + .campaign-detail-recipients-card, + .campaign-detail-side-stack { + grid-column: span 12; + } +} + +@media (max-width: 900px) { + .campaign-detail-header, + .campaign-detail-header-actions, + .campaign-detail-table-footer, + .campaign-detail-inline-tools, + .campaign-detail-edit-actions { + flex-direction: column; + align-items: stretch; + } +} + +@media (max-width: 720px) { + .campaign-detail-kpi-grid { + grid-template-columns: 1fr; + } + + .campaign-detail-heading-row { + flex-direction: column; + align-items: flex-start; + } + + .campaign-detail-edit-grid { + grid-template-columns: 1fr; + } +} + +.contacts-directory-page, +.contact-detail-page { + display: grid; + gap: 24px; +} + +.contacts-directory-header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; +} + +.contacts-directory-header h1, +.contact-detail-hero-title h1 { + margin: 0; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 32px; + line-height: 1.15; + color: #151e16; +} + +.contacts-directory-header p, +.contact-detail-hero p { + margin: 8px 0 0; + color: #64748b; + font-size: 16px; +} + +.contacts-directory-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.contacts-primary-button, +.contacts-secondary-button { + min-height: 46px; + padding: 0 18px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-weight: 700; + text-decoration: none; + cursor: pointer; +} + +.contacts-primary-button { + border: 0; + background: var(--accent); + color: #fff; + box-shadow: 0 12px 28px rgba(0, 109, 47, 0.18); +} + +.contacts-secondary-button { + border: 1px solid rgba(187, 203, 185, 0.85); + background: #fff; + color: #475569; +} + +.contacts-filter-bar, +.contacts-table-card, +.contact-detail-card, +.contact-detail-history-card, +.contact-detail-hero { + border-radius: 22px; + border: 1px solid rgba(220, 229, 216, 0.7); + background: #fff; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); +} + +.contacts-filter-bar { + padding: 16px; + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.contacts-filter-label { + display: inline-flex; + align-items: center; + gap: 8px; + color: #64748b; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.contacts-filter-input, +.contacts-filter-select, +.contacts-form-field input, +.contacts-form-field textarea { + min-height: 44px; + border: 0; + border-radius: 12px; + background: #edf6e9; + color: #151e16; + padding: 0 14px; +} + +.contacts-filter-input { + min-width: 220px; + flex: 1 1 220px; +} + +.contacts-filter-select { + padding-right: 34px; +} + +.contacts-clear-button { + margin-left: auto; + border: 0; + background: transparent; + color: var(--accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; +} + +.contacts-table-wrap { + overflow-x: auto; +} + +.contacts-table { + width: 100%; + border-collapse: collapse; +} + +.contacts-table thead { + background: rgba(237, 246, 233, 0.65); +} + +.contacts-table th, +.contacts-table td { + padding: 18px 24px; + text-align: left; + border-bottom: 1px solid rgba(220, 229, 216, 0.5); +} + +.contacts-table th { + color: #64748b; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.contacts-table tbody tr:hover { + background: rgba(237, 246, 233, 0.35); +} + +.contacts-contact-cell { + display: inline-flex; + align-items: center; + gap: 12px; + text-decoration: none; +} + +.contacts-contact-cell span:last-child { + display: grid; + gap: 3px; +} + +.contacts-contact-cell strong { + color: #151e16; +} + +.contacts-contact-cell small { + color: #64748b; + font-size: 12px; +} + +.contacts-avatar, +.contact-detail-hero-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: rgba(37, 211, 102, 0.12); + color: var(--accent); + font-weight: 800; +} + +.contacts-avatar { + width: 42px; + height: 42px; +} + +.contacts-mono-cell { + font-family: "JetBrains Mono", monospace; +} + +.contacts-status-chip { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.contacts-status-chip.is-active { + background: rgba(37, 211, 102, 0.12); + color: #16a34a; +} + +.contacts-status-chip.is-inactive { + background: rgba(60, 74, 61, 0.1); + color: #3c4a3d; +} + +.contacts-tags-inline { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.contacts-tags-inline.is-wrap { + margin-top: 6px; +} + +.contacts-tag-chip { + display: inline-flex; + align-items: center; + padding: 4px 9px; + border-radius: 8px; + background: rgba(140, 241, 225, 0.3); + color: #006f64; + font-size: 11px; + font-weight: 700; +} + +.contacts-table-actions { + text-align: right; +} + +.contacts-icon-button { + width: 36px; + height: 36px; + border: 0; + border-radius: 999px; + background: transparent; + color: #64748b; + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; + cursor: pointer; +} + +.contacts-table-footer { + padding: 16px 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + background: rgba(237, 246, 233, 0.35); +} + +.contacts-table-footer, +.contacts-table-footer span, +.contacts-table-footer div, +.contacts-rows-label span { + color: #64748b; + font-size: 14px; +} + +.contacts-table-footer strong { + color: #151e16; +} + +.contacts-pagination { + display: flex; + align-items: center; + gap: 6px; +} + +.contacts-page-button { + min-width: 36px; + height: 36px; + padding: 0 10px; + border-radius: 10px; + border: 1px solid rgba(187, 203, 185, 0.85); + background: #fff; + color: #3c4a3d; + font-weight: 700; + cursor: pointer; +} + +.contacts-page-button.is-active { + border-color: var(--accent); + background: var(--accent); + color: #fff; +} + +.contacts-page-button:disabled { + opacity: 0.5; + cursor: default; +} + +.contacts-page-gap { + padding: 0 3px; + color: #64748b; +} + +.contacts-rows-label { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.contacts-rows-label select { + border: 0; + background: transparent; + color: #151e16; +} + +.contacts-fab { + position: fixed; + right: 40px; + bottom: 40px; + width: 56px; + height: 56px; + border: 0; + border-radius: 999px; + background: var(--accent); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 16px 34px rgba(0, 109, 47, 0.22); + cursor: pointer; +} + +.contacts-modal-backdrop { + position: fixed; + inset: 0; + z-index: 90; + background: rgba(12, 16, 12, 0.36); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.contacts-modal-card { + width: min(100%, 860px); + border-radius: 24px; + background: #fff; + border: 1px solid rgba(220, 229, 216, 0.7); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.16); + padding: 24px; +} + +.contacts-modal-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} + +.contacts-modal-head p { + margin: 0 0 6px; + color: #64748b; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.contacts-modal-head h2 { + margin: 0; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 28px; + line-height: 1.2; +} + +.contacts-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.contacts-form-field { + display: grid; + gap: 8px; +} + +.contacts-form-field span, +.contacts-checkbox-row span { + color: #3c4a3d; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.contacts-form-field textarea { + min-height: 120px; + padding: 14px; + resize: vertical; +} + +.contacts-form-field.is-full, +.contacts-checkbox-row.is-full, +.contacts-form-error { + grid-column: 1 / -1; +} + +.contacts-checkbox-row { + display: flex; + align-items: center; + gap: 10px; +} + +.contacts-form-actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.contacts-form-error { + margin: 0; + color: #b91c1c; + font-size: 14px; + font-weight: 600; +} + +.contacts-form-success { + margin: 0; + color: #15803d; + font-size: 14px; + font-weight: 600; +} + +.contact-detail-breadcrumb { + display: flex; + align-items: center; + gap: 8px; + color: #64748b; + font-size: 14px; +} + +.contact-detail-breadcrumb a { + color: inherit; + text-decoration: none; +} + +.contact-detail-breadcrumb span:last-child { + color: var(--accent); + font-weight: 700; +} + +.contact-detail-hero { + padding: 28px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.contact-detail-hero-main, +.contact-detail-hero-actions { + display: flex; + align-items: center; + gap: 18px; +} + +.contact-detail-hero-avatar { + width: 96px; + height: 96px; + position: relative; + font-size: 28px; +} + +.contact-detail-hero-avatar i { + position: absolute; + right: 6px; + bottom: 6px; + width: 14px; + height: 14px; + border-radius: 999px; + background: #25d366; + border: 3px solid #fff; +} + +.contact-detail-hero-title { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.contact-detail-hero-title span { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0 12px; + border-radius: 999px; + background: #e7f1e4; + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.contact-detail-danger-icon, +.contact-detail-delete-button { + border: 0; + cursor: pointer; +} + +.contact-detail-danger-icon { + width: 48px; + height: 48px; + border-radius: 14px; + background: rgba(255, 218, 214, 0.55); + color: #ba1a1a; +} + +.contact-detail-grid { + display: grid; + grid-template-columns: 4fr 8fr; + gap: 24px; +} + +.contact-detail-sidebar { + display: grid; + gap: 24px; + align-content: start; +} + +.contact-detail-card, +.contact-detail-history-card { + padding: 24px; +} + +.contact-detail-card h2, +.contact-detail-history-card h2 { + margin: 0; + display: inline-flex; + align-items: center; + gap: 8px; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; + font-size: 18px; +} + +.contact-detail-info-list { + display: grid; + gap: 22px; + margin-top: 24px; +} + +.contact-detail-info-list label { + display: block; + margin-bottom: 6px; + color: #64748b; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.contact-detail-info-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.contact-detail-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 22px; +} + +.contact-detail-card-head button { + border: 0; + background: transparent; + color: var(--accent); + font-weight: 600; + cursor: pointer; +} + +.contact-detail-notes { + display: grid; + gap: 14px; +} + +.contact-note-card { + border-radius: 16px; + background: #edf6e9; + padding: 16px; +} + +.contact-note-card.is-emphasized { + border-left: 4px solid var(--accent); +} + +.contact-note-card p { + margin: 0 0 12px; + line-height: 1.7; +} + +.contact-note-card div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.contact-detail-tabs { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.contact-detail-tabs button { + min-height: 36px; + padding: 0 12px; + border-radius: 10px; + border: 0; + background: transparent; + color: #64748b; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + cursor: pointer; +} + +.contact-detail-tabs button.is-active { + background: #e7f1e4; + color: #3c4a3d; +} + +.contact-history-timeline { + position: relative; + margin-left: 8px; + display: grid; + gap: 26px; +} + +.contact-history-timeline::before { + content: ""; + position: absolute; + left: 11px; + top: 4px; + bottom: 4px; + width: 2px; + background: #dce5d8; +} + +.contact-history-item { + position: relative; + padding-left: 40px; +} + +.contact-history-icon { + position: absolute; + left: 0; + top: 2px; + width: 24px; + height: 24px; + border-radius: 999px; + background: #e7f1e4; + color: var(--accent); + display: inline-flex; + align-items: center; + justify-content: center; + border: 4px solid #fff; + z-index: 1; +} + +.contact-history-icon .material-symbols-outlined { + font-size: 14px; +} + +.contact-history-title { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.contact-history-title span, +.contact-history-body p + small { + color: #64748b; + font-size: 12px; +} + +.contact-history-body p { + margin: 8px 0 0; + color: #475569; + line-height: 1.7; +} + +.contact-detail-delete-button { + min-height: 46px; + padding: 0 18px; + border-radius: 12px; + background: rgba(255, 218, 214, 0.55); + color: #b91c1c; + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 700; +} + +@media (max-width: 1180px) { + .contact-detail-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 900px) { + .contacts-directory-header, + .contacts-table-footer, + .contact-detail-hero, + .contact-detail-hero-actions, + .contact-detail-card-head { + flex-direction: column; + align-items: stretch; + } + + .contacts-clear-button { + margin-left: 0; + } + + .contacts-form-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/app/invite/[token]/page.tsx b/frontend/src/app/invite/[token]/page.tsx new file mode 100644 index 0000000..28c77f0 --- /dev/null +++ b/frontend/src/app/invite/[token]/page.tsx @@ -0,0 +1,13 @@ +import { SetPasswordCard } from '../../../components/set-password-card'; +import { fetchInvitation } from '../../../lib/api'; + +type Props = { + params: Promise<{ token: string }>; +}; + +export default async function InvitePage({ params }: Props) { + const { token } = await params; + const invitation = await fetchInvitation(token); + + return ; +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..aa04681 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,15 @@ +import './globals.css'; +import type { ReactNode } from 'react'; +import { getLocale } from '../lib/i18n'; + +export default async function RootLayout({ children }: { children: ReactNode }) { + const locale = await getLocale(); + + return ( + + + {children} + + + ); +} diff --git a/frontend/src/app/login/2fa/page.tsx b/frontend/src/app/login/2fa/page.tsx new file mode 100644 index 0000000..7a1d031 --- /dev/null +++ b/frontend/src/app/login/2fa/page.tsx @@ -0,0 +1,32 @@ +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { LanguageSwitcher } from '../../../components/language-switcher'; +import { TwoFactorLoginCard } from '../../../components/two-factor-login-card'; +import { twoFactorEmailCookieName } from '../../../lib/auth'; +import { getDictionary, getLocale } from '../../../lib/i18n'; + +export default async function TwoFactorPage() { + const locale = await getLocale(); + const dict = await getDictionary(); + const email = (await cookies()).get(twoFactorEmailCookieName)?.value; + + if (!email) { + redirect('/login'); + } + + return ( + + } + /> + ); +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..780dfb7 --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,49 @@ +import { getDictionary, getLocale } from '../../lib/i18n'; +import { LanguageSwitcher } from '../../components/language-switcher'; +import { LoginForm } from '../../components/login-form'; + +export default async function LoginPage() { + const locale = await getLocale(); + const dict = await getDictionary(); + + return ( + + } + appName={dict.common.appName} + labels={{ + title: dict.login.title, + description: dict.login.description, + email: dict.login.email, + password: dict.login.password, + submit: dict.login.submit, + goToDashboard: dict.login.goToDashboard, + emailLabel: dict.login.emailLabel, + passwordLabel: dict.login.passwordLabel, + forgotPassword: dict.login.forgotPassword, + rememberMe: dict.login.rememberMe, + accessVia: dict.login.accessVia, + google: dict.login.google, + sso: dict.login.sso, + newToPlatform: dict.login.newToPlatform, + applyAccess: dict.login.applyAccess, + privacyPolicy: dict.login.privacyPolicy, + termsOfService: dict.login.termsOfService, + helpCenter: dict.login.helpCenter, + securityPreview: dict.login.securityPreview, + loginHelp: dict.login.loginHelp, + twoFactorPreview: dict.login.twoFactorPreview, + showPassword: dict.login.showPassword, + hidePassword: dict.login.hidePassword, + }} + /> + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..45b7297 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,24 @@ +import Link from 'next/link'; +import { getDictionary } from '../lib/i18n'; + +export default async function HomePage() { + const dict = await getDictionary(); + + return ( +
    +
    +

    {dict.common.appName}

    +

    {dict.home.title}

    +

    {dict.home.description}

    +
    + + {dict.login.submit} + + + {dict.common.openDashboard} + +
    +
    +
    + ); +} diff --git a/frontend/src/app/reset-password/[token]/page.tsx b/frontend/src/app/reset-password/[token]/page.tsx new file mode 100644 index 0000000..e894e68 --- /dev/null +++ b/frontend/src/app/reset-password/[token]/page.tsx @@ -0,0 +1,32 @@ +import { LanguageSwitcher } from '../../../components/language-switcher'; +import { ResetPasswordCard } from '../../../components/reset-password-card'; +import { fetchPasswordResetRequest } from '../../../lib/api'; +import { getDictionary, getLocale } from '../../../lib/i18n'; + +type Props = { + params: Promise<{ token: string }>; +}; + +export default async function ResetPasswordPage({ params }: Props) { + const { token } = await params; + const locale = await getLocale(); + const dict = await getDictionary(); + const resetRequest = await fetchPasswordResetRequest(token); + + return ( + + } + /> + ); +} diff --git a/frontend/src/components/audit-trail-board.tsx b/frontend/src/components/audit-trail-board.tsx new file mode 100644 index 0000000..76fac1c --- /dev/null +++ b/frontend/src/components/audit-trail-board.tsx @@ -0,0 +1,489 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { type AuditTrailEntry, seedAuditTrailEntries } from '../lib/audit-trail'; + +function formatDate(value: string) { + const date = new Date(value); + return { + day: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), + time: date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }), + }; +} + +function downloadCsv(rows: AuditTrailEntry[]) { + const header = ['Timestamp', 'Admin User', 'Action Type', 'Module', 'IP Address', 'Severity', 'Details']; + const csvRows = rows.map((row) => + [ + row.timestamp, + row.adminUser, + row.actionType, + row.module, + row.ipAddress, + row.severity, + row.details, + ] + .map((cell) => `"${String(cell).replaceAll('"', '""')}"`) + .join(','), + ); + + const blob = new Blob([[header.join(','), ...csvRows].join('\n')], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'audit-trail-export.csv'; + anchor.click(); + URL.revokeObjectURL(url); +} + +type Props = { + initialEntries: AuditTrailEntry[]; + initialTotal?: number; + initialPage?: number; + initialPageSize?: number; + initialTotalPages?: number; +}; + +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + +function buildVisiblePages(page: number, totalPages: number) { + if (totalPages <= 5) { + return Array.from({ length: totalPages }, (_, index) => index + 1); + } + + const start = Math.max(1, Math.min(page - 2, totalPages - 4)); + return Array.from({ length: 5 }, (_, index) => start + index); +} + +export function AuditTrailBoard({ + initialEntries, + initialTotal, + initialPage, + initialPageSize, + initialTotalPages, +}: Props) { + const [allEntries] = useState(initialEntries.length > 0 ? initialEntries : seedAuditTrailEntries); + const [entries, setEntries] = useState(initialEntries.length > 0 ? initialEntries : seedAuditTrailEntries); + const [total, setTotal] = useState(initialTotal ?? initialEntries.length); + const [page, setPage] = useState(initialPage ?? 1); + const [pageSize, setPageSize] = useState(initialPageSize ?? 50); + const [totalPages, setTotalPages] = useState(initialTotalPages ?? 1); + const [range, setRange] = useState('7d'); + const [adminUser, setAdminUser] = useState('all'); + const [actionType, setActionType] = useState('all'); + const [moduleName, setModuleName] = useState('all'); + const [search, setSearch] = useState(''); + const [selectedEntryId, setSelectedEntryId] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + setSelectedEntryId(initialEntries[0]?.id ?? seedAuditTrailEntries[0]?.id ?? null); + }, [initialEntries]); + + useEffect(() => { + const controller = new AbortController(); + const timeout = window.setTimeout(async () => { + try { + setIsLoading(true); + const params = new URLSearchParams(); + params.set('page', String(page)); + params.set('limit', String(pageSize)); + if (range !== 'all') params.set('range', range); + if (adminUser !== 'all') params.set('user', adminUser); + if (actionType !== 'all') params.set('actionType', actionType); + if (moduleName !== 'all') params.set('module', moduleName); + if (search.trim()) params.set('search', search.trim()); + + const response = await fetch(`/api/audit-trail?${params.toString()}`, { + method: 'GET', + signal: controller.signal, + cache: 'no-store', + }); + + if (!response.ok) { + return; + } + + const payload = (await response.json()) as { + items: Array<{ + id: string; + actorName: string; + actionType: string; + module: string; + ipAddress: string | null; + severity: 'default' | 'alert'; + details: string; + createdAt: string; + }>; + total: number; + page: number; + pageSize: number; + totalPages: number; + }; + + const normalized = payload.items.map((entry) => ({ + id: entry.id, + timestamp: entry.createdAt, + adminUser: entry.actorName, + actionType: entry.actionType, + module: entry.module, + ipAddress: entry.ipAddress || '-', + severity: entry.severity, + details: entry.details, + })); + + setEntries(normalized); + setTotal(payload.total); + setPage(payload.page); + setPageSize(payload.pageSize); + setTotalPages(payload.totalPages); + } catch (error) { + if ((error as Error).name !== 'AbortError') { + console.error(error); + } + } finally { + setIsLoading(false); + } + }, 250); + + return () => { + controller.abort(); + window.clearTimeout(timeout); + }; + }, [actionType, adminUser, moduleName, page, pageSize, range, search]); + + const selectedEntry = + entries.find((entry) => entry.id === selectedEntryId) ?? entries[0] ?? null; + + useEffect(() => { + if (!selectedEntryId && entries[0]?.id) { + setSelectedEntryId(entries[0].id); + return; + } + + if (selectedEntryId && !entries.some((entry) => entry.id === selectedEntryId)) { + setSelectedEntryId(entries[0]?.id ?? null); + } + }, [entries, selectedEntryId]); + + const users = Array.from(new Set(allEntries.map((entry) => entry.adminUser))); + const actions = Array.from(new Set(allEntries.map((entry) => entry.actionType))); + const modules = Array.from(new Set(allEntries.map((entry) => entry.module))); + + const alertsCount = allEntries.filter((entry) => entry.severity === 'alert').length; + const mostActiveAdmin = + users + .map((user) => ({ + user, + count: allEntries.filter((entry) => entry.adminUser === user).length, + })) + .sort((a, b) => b.count - a.count)[0] ?? { user: 'Admin User', count: 0 }; + const visiblePages = useMemo(() => buildVisiblePages(page, totalPages), [page, totalPages]); + const pageStart = entries.length === 0 ? 0 : (page - 1) * pageSize + 1; + const pageEnd = entries.length === 0 ? 0 : (page - 1) * pageSize + entries.length; + + return ( + <> +
    +
    +

    Settings

    +

    Audit Trail

    +

    Monitor administrative actions, role changes, and system modifications from one place.

    +
    + + + download + Export Server CSV + +
    + +
    +
    +
    + Total Actions (Last 24H) + history +
    +
    + {entries.length.toLocaleString('en-US')} + 12.5% +
    +
    +
    +
    +
    + +
    +
    + Security Alerts + security +
    +
    + {alertsCount.toString().padStart(2, '0')} + Critical +
    +

    Failed login bursts and suspicious access activity are surfaced here.

    +
    + +
    +
    + Most Active Admin + person +
    +
    +
    {mostActiveAdmin.user.slice(0, 1)}
    +
    + {mostActiveAdmin.user} + {mostActiveAdmin.count} actions performed +
    +
    +
    +
    + +
    +
    + filter_list + Filters +
    + + + + + + + + + + + + +
    + +
    +
    + {isLoading ?
    : null} + + + + + + + + + + + + + {entries.map((entry) => { + const stamp = formatDate(entry.timestamp); + const isSelected = entry.id === selectedEntry?.id; + return ( + + + + + + + + + ); + })} + +
    TimestampAdmin UserAction TypeModuleIP AddressActions
    +
    + {stamp.day} + {stamp.time} +
    +
    +
    +
    + {entry.adminUser.slice(0, 1)} +
    + {entry.adminUser} +
    +
    + + {entry.actionType} + + {entry.module}{entry.ipAddress} + +
    +
    +
    + + Showing {pageStart} to {pageEnd} of {total} results + + +
    +
    + + {visiblePages.map((pageNumber) => ( + + ))} + {visiblePages[visiblePages.length - 1] < totalPages ? ... : null} + {visiblePages[visiblePages.length - 1] < totalPages ? ( + + ) : null} + +
    +
    +
    + + +
    + + ); +} diff --git a/frontend/src/components/campaign-detail-actions.tsx b/frontend/src/components/campaign-detail-actions.tsx new file mode 100644 index 0000000..b9830ac --- /dev/null +++ b/frontend/src/components/campaign-detail-actions.tsx @@ -0,0 +1,324 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +type Props = { + campaign: { + id: string; + name: string; + status: string; + totalRecipients: number; + templateName: string; + language: string; + messageTitle: string; + messageBody: string; + primaryButton: string; + secondaryButton: string; + bannerImageUrl: string; + }; +}; + +function toDateTimeLocal(value: string | null | undefined) { + if (!value) return ''; + const date = new Date(value); + const offset = date.getTimezoneOffset(); + const local = new Date(date.getTime() - offset * 60000); + return local.toISOString().slice(0, 16); +} + +export function CampaignDetailActions({ campaign }: Props) { + const router = useRouter(); + const [isDuplicating, setIsDuplicating] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isSendingNow, setIsSendingNow] = useState(false); + const [isScheduling, setIsScheduling] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [message, setMessage] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [scheduledAt, setScheduledAt] = useState(''); + const [form, setForm] = useState({ + name: campaign.name, + status: campaign.status, + totalRecipients: String(campaign.totalRecipients), + templateName: campaign.templateName, + language: campaign.language, + messageTitle: campaign.messageTitle, + messageBody: campaign.messageBody, + primaryButton: campaign.primaryButton, + secondaryButton: campaign.secondaryButton, + bannerImageUrl: campaign.bannerImageUrl, + }); + + async function readPayload(response: Response) { + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(typeof payload?.message === 'string' ? payload.message : 'Request failed'); + } + + return payload; + } + + return ( + <> +
    + + + + + + download + Export CSV + + + download + Export XLSX + +
    + +
    + + +
    + + {message ?

    {message}

    : null} + + {isEditing ? ( +
    { + event.preventDefault(); + + try { + setMessage(null); + setIsSaving(true); + const response = await fetch(`/api/campaigns/${campaign.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...form, + totalRecipients: Number(form.totalRecipients || '0'), + }), + }); + await readPayload(response); + setMessage('Campaign updated successfully.'); + setIsEditing(false); + router.refresh(); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Failed to update campaign'); + } finally { + setIsSaving(false); + } + }} + > +
    + + + + + + + + + +
    +
    + +check_circle + Syntax Valid + + + {{1}} : Recipient Name + + + {{2}} : Ticket Topic + +
    + + + +
    +
    +

    Real-time Preview

    + +
    + +
    +arrow_back +
    +business +
    +
    +

    Your Brand

    +

    Official Business Account

    +
    +
    + +
    + +
    +
    +

    Hello Sarah Jenkins, thank you for choosing our services!

    +

    We've received your inquiry regarding Premium Support Plan. Our team will get back to you within 24 hours.

    +

    Best regards,
    WhatsApp Admin Team

    +
    +10:45 AM +done_all +
    +
    +
    +
    + +
    +
    +sentiment_satisfied +
    +attach_file +
    +
    +mic +
    +
    +
    + +
    +
    +lightbulb +
    +

    Pro Tip

    +

    Using variables like {{1}} ensures personalized delivery which can increase engagement by up to 40%.

    +
    +
    +
    +
    +
    + + +
    +
    +

    Estimated Cost

    +

    $0.0084 (Standard Rate)

    +
    +
    +

    Estimated Reach

    +

    1 Recipient

    +
    +
    +

    Message Status

    + + + Drafting + +
    +
    +

    Character Count

    +

    184 / 4096

    +
    +
    + + + + + \ No newline at end of file diff --git a/stitch_bizone/draft_editor_whatsapp_business_admin/screen.png b/stitch_bizone/draft_editor_whatsapp_business_admin/screen.png new file mode 100644 index 0000000..7a8062b Binary files /dev/null and b/stitch_bizone/draft_editor_whatsapp_business_admin/screen.png differ diff --git a/stitch_bizone/login_whatsapp_business_admin/code.html b/stitch_bizone/login_whatsapp_business_admin/code.html new file mode 100644 index 0000000..a8155d8 --- /dev/null +++ b/stitch_bizone/login_whatsapp_business_admin/code.html @@ -0,0 +1,211 @@ + + + + + +Login - WhatsApp Business Admin + + + + + + + + + +
    + +
    +
    +chat +
    +
    +

    WhatsApp Business Admin

    +

    Manage your enterprise communication flow

    +
    +
    + +
    + + +
    + +
    +
    +mail +
    + +
    +
    + +
    +
    + +Forgot password? +
    +
    +
    +lock +
    + + +
    +
    + +
    + +
    + + + + +
    +
    +OR ACCESS VIA +
    +
    + +
    + + +
    +
    + + +
    + + + + \ No newline at end of file diff --git a/stitch_bizone/login_whatsapp_business_admin/screen.png b/stitch_bizone/login_whatsapp_business_admin/screen.png new file mode 100644 index 0000000..1e32da1 Binary files /dev/null and b/stitch_bizone/login_whatsapp_business_admin/screen.png differ diff --git a/stitch_bizone/messages_drafts_whatsapp_business_admin/code.html b/stitch_bizone/messages_drafts_whatsapp_business_admin/code.html new file mode 100644 index 0000000..0928471 --- /dev/null +++ b/stitch_bizone/messages_drafts_whatsapp_business_admin/code.html @@ -0,0 +1,512 @@ + + + + + +WhatsApp Business Admin Console - Message List + + + + + + + + + + + +
    +
    +
    +search + +
    +
    +
    +
    +English +Bahasa +
    +
    + + +
    +
    +

    Admin User

    +

    Super Admin

    +
    +Admin Profile Image +
    +
    +
    +
    + +
    +
    + +
    +
    +

    Message Management

    +

    Monitor, track, and manage your WhatsApp business communications.

    +
    +
    + + +
    +
    + +
    +
    +
    +
    +send +
    ++12% vs last week +
    +

    Total Sent

    +

    24,592

    +
    +
    +
    +
    +schedule +
    +Next 24h +
    +

    Scheduled

    +

    1,208

    +
    +
    +
    +
    +edit_note +
    +Action required +
    +

    Drafts

    +

    45

    +
    +
    + +
    +
    +
    + + + + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    RecipientMessage PreviewStatusTimestampActions
    +
    +
    JS
    +
    +

    John Smith

    +

    +62 812-3456-7890

    +
    +
    +
    +

    Hello John! Just checking in on your recent order #8812. Let us know if you have any questions.

    +
    + + + Sent + + +

    Oct 24, 2023

    +

    10:45 AM

    +
    + +
    +
    +
    AM
    +
    +

    Alice Miller

    +

    +62 819-0012-3344

    +
    +
    +
    +

    Reminder: Your appointment is scheduled for tomorrow at 2:00 PM. Reply 1 to confirm.

    +
    + + + Scheduled + + +

    Oct 26, 2023

    +

    09:00 AM

    +
    + +
    +
    +
    RK
    +
    +

    Robert King

    +

    +62 857-7788-9900

    +
    +
    +
    +

    [No message content yet]

    +
    + + + Draft + + +

    Oct 23, 2023

    +

    04:12 PM

    +
    + +
    +
    +
    EW
    +
    +

    Elena White

    +

    +62 811-2222-3333

    +
    +
    +
    +

    Thank you for your feedback! Your 20% discount code is WELCOME20. Enjoy!

    +
    + + + Sent + + +

    Oct 22, 2023

    +

    01:30 PM

    +
    + +
    +
    + +
    +

    Showing 1 to 10 of 2,492 messages

    +
    + + + + + +
    +
    +
    + +
    +
    +
    +

    Quick Draft

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +arrow_back +
    +Recipient Avatar +
    +
    +

    Customer Service

    +

    online

    +
    +
    +videocam +call +more_vert +
    +
    + +
    +
    + Hello! How can I help you today? + 10:42 AM +
    +
    +

    Just checking in on your recent order #8812. Let us know if you have any questions.

    +
    +10:45 AM +done_all +
    +
    +
    + +
    +
    +insert_emoticon +Type a message +attach_file +photo_camera +
    +
    +mic +
    +
    +
    +
    +
    +
    +
    +
    + \ No newline at end of file diff --git a/stitch_bizone/messages_drafts_whatsapp_business_admin/screen.png b/stitch_bizone/messages_drafts_whatsapp_business_admin/screen.png new file mode 100644 index 0000000..18c0491 Binary files /dev/null and b/stitch_bizone/messages_drafts_whatsapp_business_admin/screen.png differ diff --git a/stitch_bizone/roles_permissions_whatsapp_business_admin/code.html b/stitch_bizone/roles_permissions_whatsapp_business_admin/code.html new file mode 100644 index 0000000..347ded5 --- /dev/null +++ b/stitch_bizone/roles_permissions_whatsapp_business_admin/code.html @@ -0,0 +1,481 @@ + + + + + +Roles & Permissions | WhatsApp Business Admin + + + + + + + + + + + +
    + +
    +
    +
    +search + +
    + +
    +
    +
    + + +
    +
    +
    +
    +

    Admin User

    +

    Global Admin

    +
    +Admin Avatar +
    +
    +
    + +
    +
    + +
    +
    +

    Roles & Permissions

    +

    Configure access levels and granular permissions for your team members.

    +
    + +
    + +
    + +
    +
    +
    +shield_person +
    +Active +
    +

    Admin

    +

    Full access to all modules, including system settings and billing.

    +
    +group + 2 users assigned +
    +
    + +
    +
    +
    +edit_document +
    +Standard +
    +

    Editor

    +

    Can create templates and manage campaigns, but cannot change settings.

    +
    +group + 5 users assigned +
    +
    + +
    +
    +
    +support_agent +
    +Editing Now +
    +

    Agent

    +

    Limited access to view analytics and respond to customer conversations.

    +
    +group + 14 users assigned +
    +
    +
    + +
    +
    +

    Permission Matrix: Agent

    +
    + + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Module / PermissionViewCreate/EditDeleteManage All
    +
    +campaign +Manage Campaigns +
    +
    + + + + + + + +
    +
    +monitoring +View Analytics +
    +
    + + + +
    +
    +settings +Edit Settings +
    +
    + + + + + + + +
    +
    +payments +Billing & Invoices +
    +
    + + + +
    +
    +
    + +
    +
    +
    +

    Audit Log: Recent Changes

    +View All +
    +
    +
    +
    +
    +person_edit +
    +
    +

    Admin updated 'Agent' permissions

    +

    Changed 'View Analytics' from OFF to ON

    +
    +
    +2 mins ago +
    +
    +
    +
    +add_circle +
    +
    +

    Created new role: 'Reporting Only'

    +

    Assigned to 0 users

    +
    +
    +4 hours ago +
    +
    +
    +
    +
    +

    Need help?

    +

    Learn more about how to set up granular access for high-security enterprise environments.

    + +
    +lock_person +
    +
    +
    +
    +
    + \ No newline at end of file diff --git a/stitch_bizone/roles_permissions_whatsapp_business_admin/screen.png b/stitch_bizone/roles_permissions_whatsapp_business_admin/screen.png new file mode 100644 index 0000000..391e256 Binary files /dev/null and b/stitch_bizone/roles_permissions_whatsapp_business_admin/screen.png differ diff --git a/stitch_bizone/system_logs_queue_whatsapp_business_admin/code.html b/stitch_bizone/system_logs_queue_whatsapp_business_admin/code.html new file mode 100644 index 0000000..c8ea22d --- /dev/null +++ b/stitch_bizone/system_logs_queue_whatsapp_business_admin/code.html @@ -0,0 +1,509 @@ + + + + + +WhatsApp Business Admin Console - Activity Logs + + + + + + + + + + + +
    +
    +

    Admin Dashboard

    +
    +
    +search + +
    +
    +
    +
    +English +Bahasa +
    +
    + + +Admin Profile Image +
    +
    +
    + +
    +
    + +
    +
    +

    Activity Logs & Queue Monitor

    +

    Real-time surveillance of system processes, background jobs, and administrative actions.

    +
    +
    + + +
    +
    + +
    + +
    +
    +
    +

    Queue Monitor

    + + LIVE + +
    +
    + +
    +
    +

    PENDING JOBS

    +

    1,284

    +
    +schedule +
    + +
    +
    +

    PROCESSING

    +

    42

    +
    +autorenew +
    + +
    +
    +

    FAILED (24H)

    +

    7

    +
    +error_outline +
    +
    +
    +
    Worker Health
    +
    +
    +node-worker-01 +98% Load +
    +
    +
    +
    +
    +node-worker-02 +12% Load +
    +
    +
    +
    +
    +
    +
    + +
    +
    +bolt +
    +

    Queue Throughput

    +

    System is currently processing 840 messages per minute. Optimization recommended for peak hours.

    +
    +
    +
    +
    + +
    +
    +
    +

    Technical Activity Logs

    +
    +Total: 45,290 events +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    TimestampActionUser / ServiceStatusPayload
    2023-10-27 14:22:01 +
    +BROADCAST_START +Marketing Campaign v2 +
    +
    +
    +
    AD
    +admin_jane +
    +
    +Success + + +
    2023-10-27 14:21:55 +
    +WEBHOOK_RETRY +Endpoint: /api/v1/update +
    +
    +
    +robot +QueueWorker_02 +
    +
    +Pending + + +
    2023-10-27 14:21:48 +
    +AUTH_FAILURE +Invalid Token Attempt +
    +
    +
    +public +IP: 192.168.1.1 +
    +
    +Rejected + + +
    2023-10-27 14:20:12 +
    +TEMPLATE_CREATE +New template: Welcome_V2 +
    +
    +
    +
    MK
    +mike_dev +
    +
    +Approved + + +
    2023-10-27 14:19:44 +
    +EXPORT_COMPLETE +User_Database_Daily.csv +
    +
    +
    +settings_suggest +SystemScheduler +
    +
    +Success + + +
    +
    +
    +Showing 5 of 45,290 logs +
    + + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +terminal +
    +
    +

    Live Tail Mode

    +

    Streaming real-time logs directly from the message processing engine. Use this for debugging active broadcast campaigns and webhook handshake failures.

    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +API LATENCY +bar_chart +
    +
    +124ms +↓ 12% +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +DB CONNECTIONS +database +
    +
    +84 +Active +
    +
    +
    +
    +
    +
    +
    +
    +
    +MEMORY USAGE +memory +
    +
    +4.2GB +of 16GB +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + \ No newline at end of file diff --git a/stitch_bizone/system_logs_queue_whatsapp_business_admin/screen.png b/stitch_bizone/system_logs_queue_whatsapp_business_admin/screen.png new file mode 100644 index 0000000..f85d287 Binary files /dev/null and b/stitch_bizone/system_logs_queue_whatsapp_business_admin/screen.png differ diff --git a/stitch_bizone/template_builder_whatsapp_business_admin/code.html b/stitch_bizone/template_builder_whatsapp_business_admin/code.html new file mode 100644 index 0000000..fccb0bd --- /dev/null +++ b/stitch_bizone/template_builder_whatsapp_business_admin/code.html @@ -0,0 +1,405 @@ + + + + + +WhatsApp Business - Template Builder + + + + + + + + + + + +
    +
    +

    Admin Dashboard

    +
    +
    +English +Bahasa +
    +
    +
    + +
    + + +
    +Admin Profile Image +
    +
    +
    +
    + +
    +
    + +
    +
    +

    Create Message Template

    +

    Design and submit your business messages for WhatsApp approval.

    +
    +
    + + +
    +
    + +
    + +
    +
    +

    +edit_note + Basic Details +

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +

    +subject + Message Content +

    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +Variables detected: 2 + +
    +
    +
    + + +
    +
    +
    +
    +

    +smart_button + Buttons +

    +
    +
    +ads_click +
    +

    Quick Reply: Opt-out

    +

    Label: Stop promotions

    +
    + +
    + +
    +
    +
    + +
    +
    +
    + +
    +9:41 +
    +signal_cellular_4_bar +wifi +battery_full +
    +
    + +
    + +
    +arrow_back +
    +Business Profile +
    +
    +

    Your Business

    +

    online

    +
    +videocam +call +more_vert +
    + +
    + +
    TODAY
    + +
    +
    + +

    + Hi [Alex], our Summer Sale is finally here! 🌴

    + Get up to 50% OFF on all collections using code [SUMMER50] at checkout.

    + Shop now: https://example.com/shop +

    + +
    +09:41 AM +done_all +
    +
    + + +
    +
    + +
    +
    +mood +Message +attach_file +photo_camera +
    +
    +mic +
    +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + + + \ No newline at end of file diff --git a/stitch_bizone/template_builder_whatsapp_business_admin/screen.png b/stitch_bizone/template_builder_whatsapp_business_admin/screen.png new file mode 100644 index 0000000..76ac7e5 Binary files /dev/null and b/stitch_bizone/template_builder_whatsapp_business_admin/screen.png differ diff --git a/stitch_bizone/template_list_whatsapp_business_admin/code.html b/stitch_bizone/template_list_whatsapp_business_admin/code.html new file mode 100644 index 0000000..5d64b07 --- /dev/null +++ b/stitch_bizone/template_list_whatsapp_business_admin/code.html @@ -0,0 +1,392 @@ + + + + + +WhatsApp Business Admin - Template List + + + + + + + + + + + +
    + +
    +
    +

    Admin Dashboard

    +
    +
    +English +Bahasa +
    +
    +
    +
    +search + +
    +
    + + +Admin Profile Image +
    +
    +
    + +
    + +
    +
    +

    Message Templates

    +

    Create and manage your WhatsApp message templates. All templates must be approved by WhatsApp before sending.

    +
    + +
    + +
    +
    +Category: All +expand_more +
    +
    +Status: Approved +expand_more +
    +
    +Language: All +expand_more +
    +
    + Showing 12 Templates +
    +
    + +
    + +
    +
    +
    Approved
    + +
    +

    order_confirmation_v2

    +

    UTILITY

    +
    +

    "Hi {{1}}, thank you for your order #{{2}}! We've received your payment and will notify you when it ships..."

    +
    +
    +
    +
    +schedule + Updated 2h ago +
    +edit +
    +
    + +
    +
    +
    Pending
    + +
    +

    holiday_sale_promo

    +

    MARKETING

    +
    +

    "🎉 Exclusive Holiday Sale! Get 30% OFF on all items using code FESTIVE30. Shop now at {{1}}..."

    +
    +
    +
    +
    +schedule + Updated 1d ago +
    +edit +
    +
    + +
    +
    +
    Rejected
    + +
    +

    account_recovery_otp

    +

    AUTHENTICATION

    +
    +

    "Your recovery code is {{1}}. Do not share this with anyone. This code expires in 5 minutes."

    +
    +
    +
    +
    +warning + Needs Review +
    +edit +
    +
    + +
    +
    +mail +
    +
    +

    shipping_update_express

    +
    +UTILITY + +"Good news! Your package is out for delivery..." +
    +
    + +
    +
    Approved
    +
    +
    + + +
    +
    + +
    +
    +
    Approved
    + +
    +

    welcome_onboarding_v4

    +

    MARKETING

    +
    +

    "Welcome to the community {{1}}! We're thrilled to have you here. To get started, please check out..."

    +
    +
    +
    +
    +schedule + Updated 3d ago +
    +edit +
    +
    + +
    +
    +
    Approved
    + +
    +

    appointment_remind_6h

    +

    UTILITY

    +
    +

    "Reminder: Your appointment at {{1}} starts in 6 hours. If you need to reschedule, please call..."

    +
    +
    +
    +
    +schedule + Updated 1w ago +
    +edit +
    +
    +
    + +
    +
    +contact_support +
    +
    +
    Need help with Template Guidelines?
    +

    WhatsApp has strict policies on message content. Ensure your templates follow the Business Policy to avoid rejection and maintain a high quality rating.

    +
    +
    + +
    +
    +
    +
    + \ No newline at end of file diff --git a/stitch_bizone/template_list_whatsapp_business_admin/screen.png b/stitch_bizone/template_list_whatsapp_business_admin/screen.png new file mode 100644 index 0000000..d5850b2 Binary files /dev/null and b/stitch_bizone/template_list_whatsapp_business_admin/screen.png differ diff --git a/stitch_bizone/users_roles_whatsapp_business_admin/code.html b/stitch_bizone/users_roles_whatsapp_business_admin/code.html new file mode 100644 index 0000000..f5a82b9 --- /dev/null +++ b/stitch_bizone/users_roles_whatsapp_business_admin/code.html @@ -0,0 +1,464 @@ + + + + + +User Management - WhatsApp Business Admin + + + + + + + + + + + +
    +
    +Admin Dashboard +
    +
    +search + +
    +
    +
    +
    + + +
    +
    + + +Admin Profile Image +
    +
    +
    + +
    +
    + +
    +
    +

    User Management

    +

    Manage team access levels, roles, and security permissions.

    +
    + +
    + +
    +
    +
    +

    TOTAL USERS

    +

    42

    +

    +trending_up + +3 this month +

    +
    +
    +group +
    +
    +
    +
    +

    PENDING INVITES

    +

    07

    +

    +schedule + Awaiting response +

    +
    +
    +mail +
    +
    +
    +
    +

    ACTIVE SESSIONS

    +

    18

    +

    + Current real-time activity +

    +
    +
    +bolt +
    + +
    +
    +
    + +
    +
    +

    Team Members

    +
    + + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NAME / EMAILROLELAST ACTIVESTATUSACTIONS
    +
    +
    JD
    +
    +

    Jane Doe

    +

    jane.doe@company.com

    +
    +
    +
    +ADMIN +Just now +
    + +Active +
    +
    +
    + + +
    +
    +
    +User +
    +

    Alex Miller

    +

    alex.m@company.com

    +
    +
    +
    +EDITOR +2 hours ago +
    + +Active +
    +
    +
    + + +
    +
    +
    +
    SK
    +
    +

    Sarah Khan

    +

    s.khan@company.com

    +
    +
    +
    +AGENT +Pending invite +
    + +Invited +
    +
    +
    + + +
    +
    +
    +
    RW
    +
    +

    Robert Wong

    +

    robert.wong@company.com

    +
    +
    +
    +AGENT +3 days ago +
    + +Suspended +
    +
    +
    + + +
    +
    +
    + +
    +

    Showing 1 to 4 of 42 team members

    +
    + + + + + +
    +
    +
    + +
    +
    +
    Role Permissions
    +

    Quick overview of what each team member can access across the WhatsApp Business platform.

    +
    +
    +
    +
    +verified_user +ADMIN +
    +

    Full system access, including billing, user management, and API settings.

    +
    +
    +
    +edit_note +EDITOR +
    +

    Manage broadcasts and message templates, but cannot change system settings.

    +
    +
    +
    +support_agent +AGENT +
    +

    Limited to managing active chat conversations and contact lists only.

    +
    +
    +
    +
    +
    + \ No newline at end of file diff --git a/stitch_bizone/users_roles_whatsapp_business_admin/screen.png b/stitch_bizone/users_roles_whatsapp_business_admin/screen.png new file mode 100644 index 0000000..e65caff Binary files /dev/null and b/stitch_bizone/users_roles_whatsapp_business_admin/screen.png differ diff --git a/stitch_bizone/webhook_logs_whatsapp_business_admin/code.html b/stitch_bizone/webhook_logs_whatsapp_business_admin/code.html new file mode 100644 index 0000000..8c359bd --- /dev/null +++ b/stitch_bizone/webhook_logs_whatsapp_business_admin/code.html @@ -0,0 +1,421 @@ + + + + + +Webhook Logs & Activity Monitor + + + + + + + + + + + +
    +
    +

    Admin Dashboard

    +
    +search + +
    +
    +
    + + +
    +Admin Avatar +
    +
    +
    + +
    + +
    +
    +

    Webhook Logs

    +

    Real-time monitoring of incoming and outgoing events.

    +
    +
    + + +
    +
    + +
    + +
    +
    +SUCCESS RATE +check_circle +
    +
    +99.8% + +arrow_upward + 0.2% + +
    +
    +
    +
    +
    + +
    +
    +AVG. RESPONSE TIME +speed +
    +
    +142ms + +arrow_downward + 12ms + +
    +
    +
    +
    +
    + +
    +
    +TOTAL EVENTS (24H) +history +
    +
    +1,248,302 +
    +

    Peak load: 4.2k req/sec

    +
    +
    + +
    + +
    +
    +Activity Monitor +
    + +LIVE UPDATING +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    TIMESTAMPEVENT TYPESTATUSLATENCY
    2023-11-24 14:02:45.102 +message.sent + +200 OK +128ms +chevron_right +
    2023-11-24 14:02:44.850 +message.delivered + +200 OK +92ms +chevron_right +
    2023-11-24 14:02:42.311 +message.read + +400 Error +215ms +chevron_right +
    2023-11-24 14:02:40.004 +message.sent + +200 OK +154ms +chevron_right +
    2023-11-24 14:02:38.992 +message.sent + +200 OK +133ms +chevron_right +
    2023-11-24 14:02:37.450 +message.delivered + +200 OK +110ms +chevron_right +
    +
    +
    +Showing 1-50 of 1,248,302 logs +
    + + +
    +
    +
    + +
    +
    +
    +

    Payload Viewer

    +

    Event: 562ea1...3b21

    +
    +
    + + +
    +
    +
    +
    {
    +  "id": "ev_123456789",
    +  "object": "event",
    +  "api_version": "2023-11",
    +  "created": 1700834565,
    +  "data": {
    +    "object": {
    +      "id": "msg_abc123",
    +      "to": "+1234567890",
    +      "status": "sent",
    +      "timestamp": "1700834564",
    +      "context": {
    +        "from": "+1098765432",
    +        "id": "whatsapp_biz_001"
    +      }
    +    }
    +  },
    +  "type": "message.sent",
    +  "request": {
    +    "id": "req_987654321",
    +    "idempotency_key": "550e8400-e29b-41d4-a716-446655440000"
    +  }
    +}
    +
    +
    +
    +RESPONSE PAYLOAD +200 OK +
    +
    + { "status": "success", "received": true } +
    +
    +
    +
    +
    + \ No newline at end of file diff --git a/stitch_bizone/webhook_logs_whatsapp_business_admin/screen.png b/stitch_bizone/webhook_logs_whatsapp_business_admin/screen.png new file mode 100644 index 0000000..3019108 Binary files /dev/null and b/stitch_bizone/webhook_logs_whatsapp_business_admin/screen.png differ diff --git a/stitch_bizone/whatsapp_business_admin_dashboard_system/DESIGN.md b/stitch_bizone/whatsapp_business_admin_dashboard_system/DESIGN.md new file mode 100644 index 0000000..9826511 --- /dev/null +++ b/stitch_bizone/whatsapp_business_admin_dashboard_system/DESIGN.md @@ -0,0 +1,163 @@ +--- +name: WhatsApp Business Admin Dashboard System +colors: + surface: '#f3fcef' + surface-dim: '#d3ddd0' + surface-bright: '#f3fcef' + surface-container-lowest: '#ffffff' + surface-container-low: '#edf6e9' + surface-container: '#e7f1e4' + surface-container-high: '#e2ebde' + surface-container-highest: '#dce5d8' + on-surface: '#151e16' + on-surface-variant: '#3c4a3d' + inverse-surface: '#2a332a' + inverse-on-surface: '#eaf3e6' + outline: '#6c7b6b' + outline-variant: '#bbcbb9' + surface-tint: '#006d2f' + primary: '#006d2f' + on-primary: '#ffffff' + primary-container: '#25d366' + on-primary-container: '#005523' + inverse-primary: '#3de273' + secondary: '#006b5f' + on-secondary: '#ffffff' + secondary-container: '#8cf1e1' + on-secondary-container: '#006f64' + tertiary: '#93492e' + on-tertiary: '#ffffff' + tertiary-container: '#ffa07e' + on-tertiary-container: '#78351b' + error: '#ba1a1a' + on-error: '#ffffff' + error-container: '#ffdad6' + on-error-container: '#93000a' + primary-fixed: '#66ff8e' + primary-fixed-dim: '#3de273' + on-primary-fixed: '#002109' + on-primary-fixed-variant: '#005322' + secondary-fixed: '#8ff4e3' + secondary-fixed-dim: '#72d8c8' + on-secondary-fixed: '#00201c' + on-secondary-fixed-variant: '#005047' + tertiary-fixed: '#ffdbcf' + tertiary-fixed-dim: '#ffb59b' + on-tertiary-fixed: '#380d00' + on-tertiary-fixed-variant: '#763319' + background: '#f3fcef' + on-background: '#151e16' + surface-variant: '#dce5d8' + background-main: '#F8F9FA' + surface-card: '#FFFFFF' + text-primary: '#1A1C1E' + text-secondary: '#64748B' + border-subtle: '#E2E8F0' + status-success: '#25D366' + status-warning: '#F59E0B' + status-error: '#EF4444' + status-info: '#3B82F6' +typography: + display-lg: + fontFamily: Plus Jakarta Sans + fontSize: 32px + fontWeight: '700' + lineHeight: 40px + letterSpacing: -0.02em + headline-md: + fontFamily: Plus Jakarta Sans + fontSize: 24px + fontWeight: '600' + lineHeight: 32px + letterSpacing: -0.01em + title-sm: + fontFamily: Plus Jakarta Sans + fontSize: 18px + fontWeight: '600' + lineHeight: 24px + body-md: + fontFamily: Inter + fontSize: 16px + fontWeight: '400' + lineHeight: 24px + body-sm: + fontFamily: Inter + fontSize: 14px + fontWeight: '400' + lineHeight: 20px + label-caps: + fontFamily: Inter + fontSize: 12px + fontWeight: '600' + lineHeight: 16px + letterSpacing: 0.05em + mono-code: + fontFamily: jetbrainsMono + fontSize: 13px + fontWeight: '400' + lineHeight: 20px +rounded: + sm: 0.25rem + DEFAULT: 0.5rem + md: 0.75rem + lg: 1rem + xl: 1.5rem + full: 9999px +spacing: + base: 8px + container-margin: 32px + gutter: 24px + card-padding: 20px + toolbar-height: 64px + sidebar-width: 260px +--- + +# Figma Design Brief + +Design a modern enterprise SaaS admin dashboard for a WhatsApp Business management platform. + +## Style Direction +- Clean, premium, modern +- WhatsApp-inspired green accent +- White cards +- Light gray background +- Rounded corners +- Soft shadows +- Typography: Inter or Plus Jakarta Sans + +## Required Pages +1. Login +2. Dashboard Overview +3. Contacts List +4. Contact Detail +5. Conversations Inbox +6. Messages List +7. Draft Messages +8. Draft Editor +9. Campaign List +10. Campaign Detail +11. Template List +12. Template Builder +13. Settings Overview +14. WhatsApp API Settings +15. Webhook Settings +16. Webhook Logs +17. Users Management +18. Roles and Permissions +19. Activity Logs +20. Queue Monitor + +## Required Components +- Left sidebar navigation +- Top header +- KPI cards +- Charts +- Data tables +- Status badges +- Filters +- Mobile message preview +- Webhook payload viewer drawer +- Queue monitor widgets + +## Tone +Professional, scalable, operational, suitable for internal business teams.