elevenlabs-webhook-nodejs 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (969) hide show
  1. package/.claude/settings.local.json +27 -0
  2. package/.dockerignore +6 -0
  3. package/.env.example +43 -0
  4. package/BEFORE-PRODUCTION.md +32 -0
  5. package/Dockerfile +23 -0
  6. package/SYSTEM_OVERVIEW.md +942 -0
  7. package/SYSTEM_STATUS.md +332 -0
  8. package/backup_script/main.py +146 -0
  9. package/baileys/.dockerignore +7 -0
  10. package/baileys/Dockerfile +13 -0
  11. package/baileys/README.md +412 -0
  12. package/baileys/index.js +499 -0
  13. package/baileys/package-lock.json +2532 -0
  14. package/baileys/package.json +25 -0
  15. package/baileys/server.js +96 -0
  16. package/baileys/src/config.js +55 -0
  17. package/baileys/src/middleware/api-key.js +16 -0
  18. package/baileys/src/routes/accounts.js +51 -0
  19. package/baileys/src/routes/chats.js +103 -0
  20. package/baileys/src/routes/webhooks.js +34 -0
  21. package/baileys/src/services/account-store.js +259 -0
  22. package/baileys/src/services/webhook-dispatcher.js +161 -0
  23. package/baileys/src/services/worker-manager.js +597 -0
  24. package/baileys/src/utils/jid.js +79 -0
  25. package/baileys/src/utils/logger.js +16 -0
  26. package/baileys/src/utils/use-mongodb-auth-state.js +122 -0
  27. package/baileys/worker.js +721 -0
  28. package/dist/app.d.ts +1 -0
  29. package/dist/app.js +84 -0
  30. package/dist/app.js.map +1 -0
  31. package/dist/config/agentPrompts.json +19 -0
  32. package/dist/config/database.d.ts +1 -0
  33. package/dist/config/database.js +26 -0
  34. package/dist/config/database.js.map +1 -0
  35. package/dist/config/env.d.ts +41 -0
  36. package/dist/config/env.js +81 -0
  37. package/dist/config/env.js.map +1 -0
  38. package/dist/config/index.d.ts +3 -0
  39. package/dist/config/index.js +73 -0
  40. package/dist/config/index.js.map +1 -0
  41. package/dist/controllers/webhook.controller.d.ts +10 -0
  42. package/dist/controllers/webhook.controller.js +128 -0
  43. package/dist/controllers/webhook.controller.js.map +1 -0
  44. package/dist/errors/index.d.ts +4 -0
  45. package/dist/errors/index.js +13 -0
  46. package/dist/errors/index.js.map +1 -0
  47. package/dist/hooks/webhookValidator.d.ts +2 -0
  48. package/dist/hooks/webhookValidator.js +47 -0
  49. package/dist/hooks/webhookValidator.js.map +1 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +55 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/migrations/001_session-architecture.d.ts +3 -0
  54. package/dist/migrations/001_session-architecture.js +119 -0
  55. package/dist/migrations/001_session-architecture.js.map +1 -0
  56. package/dist/migrations/002_agent-ref.d.ts +3 -0
  57. package/dist/migrations/002_agent-ref.js +55 -0
  58. package/dist/migrations/002_agent-ref.js.map +1 -0
  59. package/dist/migrations/003_shift-schedule.d.ts +3 -0
  60. package/dist/migrations/003_shift-schedule.js +48 -0
  61. package/dist/migrations/003_shift-schedule.js.map +1 -0
  62. package/dist/migrations/004_invert-shift-to-clinic-hours.d.ts +3 -0
  63. package/dist/migrations/004_invert-shift-to-clinic-hours.js +27 -0
  64. package/dist/migrations/004_invert-shift-to-clinic-hours.js.map +1 -0
  65. package/dist/migrations/005_composite-baileys-chat-ids.d.ts +3 -0
  66. package/dist/migrations/005_composite-baileys-chat-ids.js +47 -0
  67. package/dist/migrations/005_composite-baileys-chat-ids.js.map +1 -0
  68. package/dist/migrations/migration.model.d.ts +18 -0
  69. package/dist/migrations/migration.model.js +45 -0
  70. package/dist/migrations/migration.model.js.map +1 -0
  71. package/dist/migrations/migration.routes.d.ts +2 -0
  72. package/dist/migrations/migration.routes.js +37 -0
  73. package/dist/migrations/migration.routes.js.map +1 -0
  74. package/dist/migrations/migration.runner.d.ts +19 -0
  75. package/dist/migrations/migration.runner.js +74 -0
  76. package/dist/migrations/migration.runner.js.map +1 -0
  77. package/dist/models/ConversationClaim.d.ts +13 -0
  78. package/dist/models/ConversationClaim.js +44 -0
  79. package/dist/models/ConversationClaim.js.map +1 -0
  80. package/dist/models/ConversationEvaluation.d.ts +23 -0
  81. package/dist/models/ConversationEvaluation.js +92 -0
  82. package/dist/models/ConversationEvaluation.js.map +1 -0
  83. package/dist/models/Summary.d.ts +37 -0
  84. package/dist/models/Summary.js +78 -0
  85. package/dist/models/Summary.js.map +1 -0
  86. package/dist/models/Transcription.d.ts +34 -0
  87. package/dist/models/Transcription.js +71 -0
  88. package/dist/models/Transcription.js.map +1 -0
  89. package/dist/models/WhatsAppRecipient.d.ts +23 -0
  90. package/dist/models/WhatsAppRecipient.js +53 -0
  91. package/dist/models/WhatsAppRecipient.js.map +1 -0
  92. package/dist/modules/admin/admin.routes.d.ts +2 -0
  93. package/dist/modules/admin/admin.routes.js +1966 -0
  94. package/dist/modules/admin/admin.routes.js.map +1 -0
  95. package/dist/modules/admin/elevenlabs-test-chat.routes.d.ts +2 -0
  96. package/dist/modules/admin/elevenlabs-test-chat.routes.js +158 -0
  97. package/dist/modules/admin/elevenlabs-test-chat.routes.js.map +1 -0
  98. package/dist/modules/admin/whatsapp-test-chat.routes.d.ts +2 -0
  99. package/dist/modules/admin/whatsapp-test-chat.routes.js +204 -0
  100. package/dist/modules/admin/whatsapp-test-chat.routes.js.map +1 -0
  101. package/dist/modules/agents/agent.model.d.ts +38 -0
  102. package/dist/modules/agents/agent.model.js +92 -0
  103. package/dist/modules/agents/agent.model.js.map +1 -0
  104. package/dist/modules/agents/agent.routes.d.ts +2 -0
  105. package/dist/modules/agents/agent.routes.js +61 -0
  106. package/dist/modules/agents/agent.routes.js.map +1 -0
  107. package/dist/modules/appointment-validation/appointment-validation.model.d.ts +76 -0
  108. package/dist/modules/appointment-validation/appointment-validation.model.js +118 -0
  109. package/dist/modules/appointment-validation/appointment-validation.model.js.map +1 -0
  110. package/dist/modules/appointment-validation/appointment-validation.routes.d.ts +2 -0
  111. package/dist/modules/appointment-validation/appointment-validation.routes.js +202 -0
  112. package/dist/modules/appointment-validation/appointment-validation.routes.js.map +1 -0
  113. package/dist/modules/appointment-validation/appointment-validation.service.d.ts +53 -0
  114. package/dist/modules/appointment-validation/appointment-validation.service.js +827 -0
  115. package/dist/modules/appointment-validation/appointment-validation.service.js.map +1 -0
  116. package/dist/modules/auth/auth.model.d.ts +17 -0
  117. package/dist/modules/auth/auth.model.js +64 -0
  118. package/dist/modules/auth/auth.model.js.map +1 -0
  119. package/dist/modules/auth/auth.routes.d.ts +2 -0
  120. package/dist/modules/auth/auth.routes.js +202 -0
  121. package/dist/modules/auth/auth.routes.js.map +1 -0
  122. package/dist/modules/auth/auth.service.d.ts +28 -0
  123. package/dist/modules/auth/auth.service.js +183 -0
  124. package/dist/modules/auth/auth.service.js.map +1 -0
  125. package/dist/modules/auth/refresh-token.model.d.ts +13 -0
  126. package/dist/modules/auth/refresh-token.model.js +52 -0
  127. package/dist/modules/auth/refresh-token.model.js.map +1 -0
  128. package/dist/modules/billing/billing-alert.model.d.ts +16 -0
  129. package/dist/modules/billing/billing-alert.model.js +47 -0
  130. package/dist/modules/billing/billing-alert.model.js.map +1 -0
  131. package/dist/modules/billing/billing-period-snapshot.model.d.ts +35 -0
  132. package/dist/modules/billing/billing-period-snapshot.model.js +68 -0
  133. package/dist/modules/billing/billing-period-snapshot.model.js.map +1 -0
  134. package/dist/modules/billing/billing.model.d.ts +18 -0
  135. package/dist/modules/billing/billing.model.js +62 -0
  136. package/dist/modules/billing/billing.model.js.map +1 -0
  137. package/dist/modules/billing/billing.routes.d.ts +2 -0
  138. package/dist/modules/billing/billing.routes.js +63 -0
  139. package/dist/modules/billing/billing.routes.js.map +1 -0
  140. package/dist/modules/billing/billing.service.d.ts +69 -0
  141. package/dist/modules/billing/billing.service.js +498 -0
  142. package/dist/modules/billing/billing.service.js.map +1 -0
  143. package/dist/modules/billing/payment.model.d.ts +24 -0
  144. package/dist/modules/billing/payment.model.js +57 -0
  145. package/dist/modules/billing/payment.model.js.map +1 -0
  146. package/dist/modules/calls/call.model.d.ts +41 -0
  147. package/dist/modules/calls/call.model.js +97 -0
  148. package/dist/modules/calls/call.model.js.map +1 -0
  149. package/dist/modules/calls/call.routes.d.ts +2 -0
  150. package/dist/modules/calls/call.routes.js +103 -0
  151. package/dist/modules/calls/call.routes.js.map +1 -0
  152. package/dist/modules/campaigns/campaign.model.d.ts +45 -0
  153. package/dist/modules/campaigns/campaign.model.js +98 -0
  154. package/dist/modules/campaigns/campaign.model.js.map +1 -0
  155. package/dist/modules/campaigns/campaign.routes.d.ts +2 -0
  156. package/dist/modules/campaigns/campaign.routes.js +323 -0
  157. package/dist/modules/campaigns/campaign.routes.js.map +1 -0
  158. package/dist/modules/campaigns/campaign.service.d.ts +11 -0
  159. package/dist/modules/campaigns/campaign.service.js +86 -0
  160. package/dist/modules/campaigns/campaign.service.js.map +1 -0
  161. package/dist/modules/google-calendar/google-calendar.routes.d.ts +2 -0
  162. package/dist/modules/google-calendar/google-calendar.routes.js +32 -0
  163. package/dist/modules/google-calendar/google-calendar.routes.js.map +1 -0
  164. package/dist/modules/inbound-call/inbound-call-config.model.d.ts +20 -0
  165. package/dist/modules/inbound-call/inbound-call-config.model.js +68 -0
  166. package/dist/modules/inbound-call/inbound-call-config.model.js.map +1 -0
  167. package/dist/modules/inbound-call/inbound-call.routes.d.ts +2 -0
  168. package/dist/modules/inbound-call/inbound-call.routes.js +243 -0
  169. package/dist/modules/inbound-call/inbound-call.routes.js.map +1 -0
  170. package/dist/modules/leads/lead.model.d.ts +24 -0
  171. package/dist/modules/leads/lead.model.js +54 -0
  172. package/dist/modules/leads/lead.model.js.map +1 -0
  173. package/dist/modules/leads/lead.routes.d.ts +2 -0
  174. package/dist/modules/leads/lead.routes.js +201 -0
  175. package/dist/modules/leads/lead.routes.js.map +1 -0
  176. package/dist/modules/plans/plan.model.d.ts +25 -0
  177. package/dist/modules/plans/plan.model.js +59 -0
  178. package/dist/modules/plans/plan.model.js.map +1 -0
  179. package/dist/modules/surveys/survey.model.d.ts +30 -0
  180. package/dist/modules/surveys/survey.model.js +75 -0
  181. package/dist/modules/surveys/survey.model.js.map +1 -0
  182. package/dist/modules/surveys/survey.routes.d.ts +2 -0
  183. package/dist/modules/surveys/survey.routes.js +153 -0
  184. package/dist/modules/surveys/survey.routes.js.map +1 -0
  185. package/dist/modules/tenants/tenant.model.d.ts +86 -0
  186. package/dist/modules/tenants/tenant.model.js +127 -0
  187. package/dist/modules/tenants/tenant.model.js.map +1 -0
  188. package/dist/modules/tenants/tenant.routes.d.ts +2 -0
  189. package/dist/modules/tenants/tenant.routes.js +65 -0
  190. package/dist/modules/tenants/tenant.routes.js.map +1 -0
  191. package/dist/modules/users/user.routes.d.ts +2 -0
  192. package/dist/modules/users/user.routes.js +106 -0
  193. package/dist/modules/users/user.routes.js.map +1 -0
  194. package/dist/modules/webhooks/elevenlabs-tool.routes.d.ts +20 -0
  195. package/dist/modules/webhooks/elevenlabs-tool.routes.js +85 -0
  196. package/dist/modules/webhooks/elevenlabs-tool.routes.js.map +1 -0
  197. package/dist/modules/webhooks/elevenlabs-tool.service.d.ts +11 -0
  198. package/dist/modules/webhooks/elevenlabs-tool.service.js +360 -0
  199. package/dist/modules/webhooks/elevenlabs-tool.service.js.map +1 -0
  200. package/dist/modules/webhooks/elevenlabs.routes.d.ts +2 -0
  201. package/dist/modules/webhooks/elevenlabs.routes.js +34 -0
  202. package/dist/modules/webhooks/elevenlabs.routes.js.map +1 -0
  203. package/dist/modules/webhooks/elevenlabs.service.d.ts +6 -0
  204. package/dist/modules/webhooks/elevenlabs.service.js +512 -0
  205. package/dist/modules/webhooks/elevenlabs.service.js.map +1 -0
  206. package/dist/modules/webhooks/unipile.routes.d.ts +2 -0
  207. package/dist/modules/webhooks/unipile.routes.js +780 -0
  208. package/dist/modules/webhooks/unipile.routes.js.map +1 -0
  209. package/dist/modules/whatsapp/appointment.model.d.ts +27 -0
  210. package/dist/modules/whatsapp/appointment.model.js +58 -0
  211. package/dist/modules/whatsapp/appointment.model.js.map +1 -0
  212. package/dist/modules/whatsapp/operator-request.model.d.ts +29 -0
  213. package/dist/modules/whatsapp/operator-request.model.js +65 -0
  214. package/dist/modules/whatsapp/operator-request.model.js.map +1 -0
  215. package/dist/modules/whatsapp/stt-usage.model.d.ts +16 -0
  216. package/dist/modules/whatsapp/stt-usage.model.js +53 -0
  217. package/dist/modules/whatsapp/stt-usage.model.js.map +1 -0
  218. package/dist/modules/whatsapp/whatsapp-chat.model.d.ts +23 -0
  219. package/dist/modules/whatsapp/whatsapp-chat.model.js +55 -0
  220. package/dist/modules/whatsapp/whatsapp-chat.model.js.map +1 -0
  221. package/dist/modules/whatsapp/whatsapp-contact-profile.model.d.ts +23 -0
  222. package/dist/modules/whatsapp/whatsapp-contact-profile.model.js +54 -0
  223. package/dist/modules/whatsapp/whatsapp-contact-profile.model.js.map +1 -0
  224. package/dist/modules/whatsapp/whatsapp-message.model.d.ts +30 -0
  225. package/dist/modules/whatsapp/whatsapp-message.model.js +52 -0
  226. package/dist/modules/whatsapp/whatsapp-message.model.js.map +1 -0
  227. package/dist/modules/whatsapp/whatsapp-session.model.d.ts +33 -0
  228. package/dist/modules/whatsapp/whatsapp-session.model.js +65 -0
  229. package/dist/modules/whatsapp/whatsapp-session.model.js.map +1 -0
  230. package/dist/modules/whatsapp/whatsapp.routes.d.ts +2 -0
  231. package/dist/modules/whatsapp/whatsapp.routes.js +1237 -0
  232. package/dist/modules/whatsapp/whatsapp.routes.js.map +1 -0
  233. package/dist/plugins/cors.d.ts +3 -0
  234. package/dist/plugins/cors.js +11 -0
  235. package/dist/plugins/cors.js.map +1 -0
  236. package/dist/plugins/jwt.d.ts +3 -0
  237. package/dist/plugins/jwt.js +22 -0
  238. package/dist/plugins/jwt.js.map +1 -0
  239. package/dist/plugins/rawBody.d.ts +3 -0
  240. package/dist/plugins/rawBody.js +16 -0
  241. package/dist/plugins/rawBody.js.map +1 -0
  242. package/dist/plugins/rbac.d.ts +5 -0
  243. package/dist/plugins/rbac.js +29 -0
  244. package/dist/plugins/rbac.js.map +1 -0
  245. package/dist/routes/admin.routes.d.ts +2 -0
  246. package/dist/routes/admin.routes.js +169 -0
  247. package/dist/routes/admin.routes.js.map +1 -0
  248. package/dist/routes/health.routes.d.ts +2 -0
  249. package/dist/routes/health.routes.js +16 -0
  250. package/dist/routes/health.routes.js.map +1 -0
  251. package/dist/routes/webhook.routes.d.ts +2 -0
  252. package/dist/routes/webhook.routes.js +17 -0
  253. package/dist/routes/webhook.routes.js.map +1 -0
  254. package/dist/services/ai/base.ai.d.ts +19 -0
  255. package/dist/services/ai/base.ai.js +120 -0
  256. package/dist/services/ai/base.ai.js.map +1 -0
  257. package/dist/services/ai/gemini.service.d.ts +11 -0
  258. package/dist/services/ai/gemini.service.js +43 -0
  259. package/dist/services/ai/gemini.service.js.map +1 -0
  260. package/dist/services/ai/index.d.ts +2 -0
  261. package/dist/services/ai/index.js +26 -0
  262. package/dist/services/ai/index.js.map +1 -0
  263. package/dist/services/ai/openai.service.d.ts +11 -0
  264. package/dist/services/ai/openai.service.js +50 -0
  265. package/dist/services/ai/openai.service.js.map +1 -0
  266. package/dist/services/elevenlabs.service.d.ts +52 -0
  267. package/dist/services/elevenlabs.service.js +447 -0
  268. package/dist/services/elevenlabs.service.js.map +1 -0
  269. package/dist/services/google-calendar.service.d.ts +60 -0
  270. package/dist/services/google-calendar.service.js +494 -0
  271. package/dist/services/google-calendar.service.js.map +1 -0
  272. package/dist/services/inbound-call-schedule.service.d.ts +11 -0
  273. package/dist/services/inbound-call-schedule.service.js +162 -0
  274. package/dist/services/inbound-call-schedule.service.js.map +1 -0
  275. package/dist/services/netgsm.service.d.ts +41 -0
  276. package/dist/services/netgsm.service.js +89 -0
  277. package/dist/services/netgsm.service.js.map +1 -0
  278. package/dist/services/unipile.service.d.ts +41 -0
  279. package/dist/services/unipile.service.js +149 -0
  280. package/dist/services/unipile.service.js.map +1 -0
  281. package/dist/services/whatsapp-agent.service.d.ts +139 -0
  282. package/dist/services/whatsapp-agent.service.js +2055 -0
  283. package/dist/services/whatsapp-agent.service.js.map +1 -0
  284. package/dist/services/whatsapp.service.d.ts +26 -0
  285. package/dist/services/whatsapp.service.js +206 -0
  286. package/dist/services/whatsapp.service.js.map +1 -0
  287. package/dist/templates/index.d.ts +39 -0
  288. package/dist/templates/index.js +35 -0
  289. package/dist/templates/index.js.map +1 -0
  290. package/dist/templates/receptionist.d.ts +2 -0
  291. package/dist/templates/receptionist.js +39 -0
  292. package/dist/templates/receptionist.js.map +1 -0
  293. package/dist/templates/survey.d.ts +2 -0
  294. package/dist/templates/survey.js +41 -0
  295. package/dist/templates/survey.js.map +1 -0
  296. package/dist/types/index.d.ts +173 -0
  297. package/dist/types/index.js +3 -0
  298. package/dist/types/index.js.map +1 -0
  299. package/dist/utils/logger.d.ts +3 -0
  300. package/dist/utils/logger.js +105 -0
  301. package/dist/utils/logger.js.map +1 -0
  302. package/docker-compose.nestjs.yml +89 -0
  303. package/docker-compose.yml +78 -0
  304. package/docs/AI_AGENT_ENHANCEMENT_PLAN.md +164 -0
  305. package/docs/API.md +1193 -0
  306. package/docs/API_ENDPOINTS.md +344 -0
  307. package/docs/ARCHITECTURE.md +305 -0
  308. package/docs/AUTH_API.md +252 -0
  309. package/docs/BILLING_SMS_ALERTS.md +94 -0
  310. package/docs/CHAT_ASSIGNMENT_SYSTEM.md +118 -0
  311. package/docs/CLIENT_TOOLS_AND_FEATURES.md +337 -0
  312. package/docs/ELEVENLABS_WEBHOOK_TOOLS.md +644 -0
  313. package/docs/FRONTEND_CHECKLIST.md +227 -0
  314. package/docs/IMPLEMENTATION_STATUS.md +470 -0
  315. package/docs/MIGRATION_GUIDE.md +96 -0
  316. package/docs/MISSINGS_REPORT.md +507 -0
  317. package/docs/NESTJS_MIGRATION_REFERENCE.md +5136 -0
  318. package/docs/PROJECT_DESCRIPTION.md +1038 -0
  319. package/docs/SCALING.md +148 -0
  320. package/docs/SESSION_SUMMARY_2026_03_17.md +135 -0
  321. package/docs/WHATSAPP_AGENT.md +1086 -0
  322. package/docs/architecture/00-SYSTEM-OVERVIEW.md +318 -0
  323. package/docs/architecture/01-DATABASE-SCHEMA.md +2564 -0
  324. package/docs/architecture/02-AUTHENTICATION.md +1040 -0
  325. package/docs/architecture/03-MULTI-CLINIC.md +742 -0
  326. package/docs/architecture/04-WHATSAPP-AGENT.md +608 -0
  327. package/docs/architecture/05-OPERATOR-WORKFLOW.md +444 -0
  328. package/docs/architecture/06-BAILEYS-MICROSERVICE.md +616 -0
  329. package/docs/architecture/07-APPOINTMENTS.md +849 -0
  330. package/docs/architecture/08-VOICE-CALLS.md +470 -0
  331. package/docs/architecture/09-OUTBOUND-CAMPAIGNS.md +542 -0
  332. package/docs/architecture/10-CLIENT-TOOLS.md +665 -0
  333. package/docs/architecture/11-BILLING.md +458 -0
  334. package/docs/architecture/12-SECURITY.md +216 -0
  335. package/docs/architecture/13-LOGGING-AUDIT.md +549 -0
  336. package/docs/architecture/14-META-BUSINESS-API.md +454 -0
  337. package/docs/architecture/15-AI-MODULE.md +479 -0
  338. package/docs/architecture/16-BACKGROUND-JOBS.md +469 -0
  339. package/docs/architecture/17-REALTIME-LIVECHAT.md +447 -0
  340. package/docs/architecture/18-FILE-STORAGE.md +410 -0
  341. package/docs/architecture/19-PATIENTS.md +1034 -0
  342. package/docs/architecture/20-TREATMENTS-AND-PLANS.md +774 -0
  343. package/docs/architecture/21-BEFORE-AFTER-PHOTOS.md +519 -0
  344. package/docs/database.md +456 -0
  345. package/docs/ornek-randevu-onay.csv +3 -0
  346. package/ecosystem.config.js +16 -0
  347. package/elevenlabs-convai-api-reference.md +1171 -0
  348. package/frontend/.dockerignore +2 -0
  349. package/frontend/Dockerfile +24 -0
  350. package/frontend/README.md +75 -0
  351. package/frontend/components.json +25 -0
  352. package/frontend/eslint.config.js +23 -0
  353. package/frontend/index.html +13 -0
  354. package/frontend/nginx.conf +37 -0
  355. package/frontend/package-lock.json +8709 -0
  356. package/frontend/package.json +71 -0
  357. package/frontend/public/favicon.svg +1 -0
  358. package/frontend/public/icons.svg +24 -0
  359. package/frontend/src/App.tsx +125 -0
  360. package/frontend/src/components/error-boundary.tsx +50 -0
  361. package/frontend/src/components/shared/activity-timeline.tsx +66 -0
  362. package/frontend/src/components/shared/appointment-calendar.css +80 -0
  363. package/frontend/src/components/shared/appointment-calendar.tsx +245 -0
  364. package/frontend/src/components/shared/chat-bubble.tsx +72 -0
  365. package/frontend/src/components/shared/combobox.tsx +119 -0
  366. package/frontend/src/components/shared/confirm-dialog.tsx +57 -0
  367. package/frontend/src/components/shared/data-table-pagination.tsx +97 -0
  368. package/frontend/src/components/shared/data-table-toolbar.tsx +39 -0
  369. package/frontend/src/components/shared/empty-state.tsx +27 -0
  370. package/frontend/src/components/shared/file-upload.tsx +140 -0
  371. package/frontend/src/components/shared/index.ts +20 -0
  372. package/frontend/src/components/shared/language-switcher.tsx +29 -0
  373. package/frontend/src/components/shared/notification-dropdown.tsx +115 -0
  374. package/frontend/src/components/shared/page-header.tsx +23 -0
  375. package/frontend/src/components/shared/stat-card.tsx +37 -0
  376. package/frontend/src/components/shared/status-badge.tsx +70 -0
  377. package/frontend/src/components/shared/status-dot.tsx +43 -0
  378. package/frontend/src/components/ui/alert-dialog.tsx +187 -0
  379. package/frontend/src/components/ui/alert.tsx +76 -0
  380. package/frontend/src/components/ui/avatar.tsx +109 -0
  381. package/frontend/src/components/ui/badge.tsx +52 -0
  382. package/frontend/src/components/ui/breadcrumb.tsx +125 -0
  383. package/frontend/src/components/ui/button.tsx +60 -0
  384. package/frontend/src/components/ui/calendar.tsx +219 -0
  385. package/frontend/src/components/ui/card.tsx +103 -0
  386. package/frontend/src/components/ui/chart.tsx +371 -0
  387. package/frontend/src/components/ui/checkbox.tsx +29 -0
  388. package/frontend/src/components/ui/collapsible.tsx +19 -0
  389. package/frontend/src/components/ui/command.tsx +194 -0
  390. package/frontend/src/components/ui/dialog.tsx +158 -0
  391. package/frontend/src/components/ui/dropdown-menu.tsx +268 -0
  392. package/frontend/src/components/ui/input-group.tsx +156 -0
  393. package/frontend/src/components/ui/input.tsx +20 -0
  394. package/frontend/src/components/ui/label.tsx +20 -0
  395. package/frontend/src/components/ui/pagination.tsx +130 -0
  396. package/frontend/src/components/ui/popover.tsx +90 -0
  397. package/frontend/src/components/ui/progress.tsx +83 -0
  398. package/frontend/src/components/ui/radio-group.tsx +36 -0
  399. package/frontend/src/components/ui/scroll-area.tsx +54 -0
  400. package/frontend/src/components/ui/select.tsx +199 -0
  401. package/frontend/src/components/ui/separator.tsx +23 -0
  402. package/frontend/src/components/ui/sheet.tsx +138 -0
  403. package/frontend/src/components/ui/sidebar.tsx +723 -0
  404. package/frontend/src/components/ui/skeleton.tsx +13 -0
  405. package/frontend/src/components/ui/sonner.tsx +47 -0
  406. package/frontend/src/components/ui/switch.tsx +30 -0
  407. package/frontend/src/components/ui/table.tsx +114 -0
  408. package/frontend/src/components/ui/tabs.tsx +82 -0
  409. package/frontend/src/components/ui/textarea.tsx +18 -0
  410. package/frontend/src/components/ui/toggle-group.tsx +89 -0
  411. package/frontend/src/components/ui/toggle.tsx +43 -0
  412. package/frontend/src/components/ui/tooltip.tsx +64 -0
  413. package/frontend/src/hooks/use-mobile.ts +19 -0
  414. package/frontend/src/i18n/index.ts +20 -0
  415. package/frontend/src/i18n/locales/en.json +786 -0
  416. package/frontend/src/i18n/locales/tr.json +786 -0
  417. package/frontend/src/index.css +134 -0
  418. package/frontend/src/layouts/admin-layout.tsx +111 -0
  419. package/frontend/src/layouts/app-header.tsx +130 -0
  420. package/frontend/src/layouts/app-layout.tsx +19 -0
  421. package/frontend/src/layouts/app-sidebar.tsx +212 -0
  422. package/frontend/src/layouts/auth-guard.tsx +31 -0
  423. package/frontend/src/layouts/mobile-sidebar.tsx +120 -0
  424. package/frontend/src/lib/api.ts +68 -0
  425. package/frontend/src/lib/hooks/use-appointments.ts +224 -0
  426. package/frontend/src/lib/hooks/use-availability-blocks.ts +83 -0
  427. package/frontend/src/lib/hooks/use-chats.ts +236 -0
  428. package/frontend/src/lib/hooks/use-clinic-detail.ts +171 -0
  429. package/frontend/src/lib/hooks/use-clinics.ts +37 -0
  430. package/frontend/src/lib/hooks/use-doctor-calendar.ts +80 -0
  431. package/frontend/src/lib/hooks/use-examinations.ts +89 -0
  432. package/frontend/src/lib/hooks/use-medical-history.ts +52 -0
  433. package/frontend/src/lib/hooks/use-patients.ts +296 -0
  434. package/frontend/src/lib/hooks/use-photo-sets.ts +118 -0
  435. package/frontend/src/lib/hooks/use-treatment-plans.ts +152 -0
  436. package/frontend/src/lib/hooks/use-treatments.ts +125 -0
  437. package/frontend/src/lib/hooks/use-users.ts +172 -0
  438. package/frontend/src/lib/utils.ts +6 -0
  439. package/frontend/src/main.tsx +17 -0
  440. package/frontend/src/pages/admin/agents/detail.tsx +774 -0
  441. package/frontend/src/pages/admin/agents/import.tsx +280 -0
  442. package/frontend/src/pages/admin/agents/index.tsx +245 -0
  443. package/frontend/src/pages/admin/ai-playground.tsx +543 -0
  444. package/frontend/src/pages/appointments/create-appointment-dialog.tsx +390 -0
  445. package/frontend/src/pages/appointments/index.tsx +860 -0
  446. package/frontend/src/pages/audit/index.tsx +24 -0
  447. package/frontend/src/pages/auth/login.tsx +194 -0
  448. package/frontend/src/pages/billing/index.tsx +24 -0
  449. package/frontend/src/pages/calendar/index.tsx +704 -0
  450. package/frontend/src/pages/calendar-connections/index.tsx +295 -0
  451. package/frontend/src/pages/calls/index.tsx +24 -0
  452. package/frontend/src/pages/campaigns/index.tsx +24 -0
  453. package/frontend/src/pages/chats/index.tsx +981 -0
  454. package/frontend/src/pages/clinics/index.tsx +224 -0
  455. package/frontend/src/pages/clinics/settings.tsx +412 -0
  456. package/frontend/src/pages/components-showcase.tsx +773 -0
  457. package/frontend/src/pages/connections/index.tsx +328 -0
  458. package/frontend/src/pages/dashboard.tsx +50 -0
  459. package/frontend/src/pages/leads/index.tsx +24 -0
  460. package/frontend/src/pages/my-schedule/index.tsx +496 -0
  461. package/frontend/src/pages/patients/create-patient-dialog.tsx +358 -0
  462. package/frontend/src/pages/patients/detail.tsx +1195 -0
  463. package/frontend/src/pages/patients/edit-patient-dialog.tsx +387 -0
  464. package/frontend/src/pages/patients/examinations-tab.tsx +460 -0
  465. package/frontend/src/pages/patients/index.tsx +381 -0
  466. package/frontend/src/pages/patients/medical-history-dialog.tsx +207 -0
  467. package/frontend/src/pages/patients/photo-sets-tab.tsx +616 -0
  468. package/frontend/src/pages/patients/timeline-tab.tsx +164 -0
  469. package/frontend/src/pages/patients/treatment-plans-tab.tsx +598 -0
  470. package/frontend/src/pages/phone-numbers/detail.tsx +427 -0
  471. package/frontend/src/pages/phone-numbers/index.tsx +455 -0
  472. package/frontend/src/pages/platform/index.tsx +454 -0
  473. package/frontend/src/pages/settings/index.tsx +126 -0
  474. package/frontend/src/pages/treatments/index.tsx +487 -0
  475. package/frontend/src/pages/users/doctor-profile.tsx +672 -0
  476. package/frontend/src/pages/users/edit.tsx +329 -0
  477. package/frontend/src/pages/users/index.tsx +407 -0
  478. package/frontend/src/pages/validation/index.tsx +24 -0
  479. package/frontend/src/stores/auth.store.ts +108 -0
  480. package/frontend/src/stores/ui.store.ts +41 -0
  481. package/frontend/tsconfig.app.json +32 -0
  482. package/frontend/tsconfig.json +13 -0
  483. package/frontend/tsconfig.node.json +26 -0
  484. package/frontend/vite.config.ts +29 -0
  485. package/nestjs/.dockerignore +5 -0
  486. package/nestjs/.env.docker +64 -0
  487. package/nestjs/.prettierrc +4 -0
  488. package/nestjs/Dockerfile +36 -0
  489. package/nestjs/README.md +98 -0
  490. package/nestjs/eslint.config.mjs +35 -0
  491. package/nestjs/nest-cli.json +8 -0
  492. package/nestjs/package-lock.json +13390 -0
  493. package/nestjs/package.json +114 -0
  494. package/nestjs/prisma/migrations/20260409161536_add_message_metadata_fields/migration.sql +1746 -0
  495. package/nestjs/prisma/migrations/20260410140436_add_agent_ai_fields/migration.sql +36 -0
  496. package/nestjs/prisma/migrations/20260410175519_add_agent_clinic_assignments/migration.sql +21 -0
  497. package/nestjs/prisma/migrations/20260412094344_make_agent_tenant_optional/migration.sql +2 -0
  498. package/nestjs/prisma/migrations/20260412110008_add_admin_chat_sessions/migration.sql +47 -0
  499. package/nestjs/prisma/migrations/migration_lock.toml +3 -0
  500. package/nestjs/prisma/schema.prisma +1843 -0
  501. package/nestjs/prisma/seed.ts +375 -0
  502. package/nestjs/prisma.config.ts +14 -0
  503. package/nestjs/src/admin/admin.controller.ts +27 -0
  504. package/nestjs/src/admin/admin.module.ts +16 -0
  505. package/nestjs/src/admin/admin.service.ts +91 -0
  506. package/nestjs/src/admin/ai-chat-session.service.ts +454 -0
  507. package/nestjs/src/admin/ai-test.controller.ts +191 -0
  508. package/nestjs/src/admin/superadmin.controller.ts +106 -0
  509. package/nestjs/src/agents/agents.controller.ts +262 -0
  510. package/nestjs/src/agents/agents.module.ts +13 -0
  511. package/nestjs/src/agents/agents.service.ts +733 -0
  512. package/nestjs/src/agents/dto/create-agent.dto.ts +99 -0
  513. package/nestjs/src/agents/dto/index.ts +2 -0
  514. package/nestjs/src/agents/dto/update-agent.dto.ts +148 -0
  515. package/nestjs/src/app.module.ts +115 -0
  516. package/nestjs/src/appointment-validation/appointment-validation.controller.ts +194 -0
  517. package/nestjs/src/appointment-validation/appointment-validation.module.ts +16 -0
  518. package/nestjs/src/appointment-validation/appointment-validation.service.ts +450 -0
  519. package/nestjs/src/appointment-validation/dto/create-batch.dto.ts +105 -0
  520. package/nestjs/src/appointment-validation/dto/index.ts +1 -0
  521. package/nestjs/src/appointment-validation/processors/validation-dispatch.processor.ts +26 -0
  522. package/nestjs/src/appointment-validation/processors/validation-sync.processor.ts +23 -0
  523. package/nestjs/src/appointments/appointments.controller.ts +268 -0
  524. package/nestjs/src/appointments/appointments.module.ts +13 -0
  525. package/nestjs/src/appointments/appointments.service.ts +773 -0
  526. package/nestjs/src/appointments/dto/create-appointment.dto.ts +72 -0
  527. package/nestjs/src/appointments/dto/index.ts +4 -0
  528. package/nestjs/src/appointments/dto/query-appointments.dto.ts +60 -0
  529. package/nestjs/src/appointments/dto/update-appointment.dto.ts +43 -0
  530. package/nestjs/src/appointments/dto/update-status.dto.ts +18 -0
  531. package/nestjs/src/appointments/processors/reminder.processor.ts +243 -0
  532. package/nestjs/src/audit/audit.controller.ts +84 -0
  533. package/nestjs/src/audit/audit.decorator.ts +20 -0
  534. package/nestjs/src/audit/audit.interceptor.ts +67 -0
  535. package/nestjs/src/audit/audit.interfaces.ts +15 -0
  536. package/nestjs/src/audit/audit.module.ts +12 -0
  537. package/nestjs/src/audit/audit.service.ts +177 -0
  538. package/nestjs/src/auth/auth.controller.ts +116 -0
  539. package/nestjs/src/auth/auth.module.ts +25 -0
  540. package/nestjs/src/auth/auth.service.ts +612 -0
  541. package/nestjs/src/auth/dto/change-password.dto.ts +13 -0
  542. package/nestjs/src/auth/dto/forgot-password.dto.ts +8 -0
  543. package/nestjs/src/auth/dto/index.ts +6 -0
  544. package/nestjs/src/auth/dto/login.dto.ts +13 -0
  545. package/nestjs/src/auth/dto/refresh.dto.ts +8 -0
  546. package/nestjs/src/auth/dto/register.dto.ts +28 -0
  547. package/nestjs/src/auth/dto/reset-password.dto.ts +13 -0
  548. package/nestjs/src/auth/permissions.config.ts +85 -0
  549. package/nestjs/src/availability-blocks/availability-blocks.controller.ts +83 -0
  550. package/nestjs/src/availability-blocks/availability-blocks.module.ts +10 -0
  551. package/nestjs/src/availability-blocks/availability-blocks.service.ts +202 -0
  552. package/nestjs/src/billing/billing.controller.ts +104 -0
  553. package/nestjs/src/billing/billing.module.ts +12 -0
  554. package/nestjs/src/billing/billing.service.ts +398 -0
  555. package/nestjs/src/billing/dto/index.ts +2 -0
  556. package/nestjs/src/billing/dto/query-billing.dto.ts +29 -0
  557. package/nestjs/src/billing/dto/record-payment.dto.ts +60 -0
  558. package/nestjs/src/billing/processors/alerts.processor.ts +216 -0
  559. package/nestjs/src/billing/processors/snapshot.processor.ts +181 -0
  560. package/nestjs/src/calls/calls.controller.ts +92 -0
  561. package/nestjs/src/calls/calls.module.ts +10 -0
  562. package/nestjs/src/calls/calls.service.ts +359 -0
  563. package/nestjs/src/calls/dto/index.ts +1 -0
  564. package/nestjs/src/calls/dto/query-calls.dto.ts +46 -0
  565. package/nestjs/src/clinics/clinics.controller.ts +226 -0
  566. package/nestjs/src/clinics/clinics.module.ts +11 -0
  567. package/nestjs/src/clinics/clinics.service.ts +203 -0
  568. package/nestjs/src/clinics/dto/create-clinic.dto.ts +40 -0
  569. package/nestjs/src/clinics/dto/create-meta-connection.dto.ts +44 -0
  570. package/nestjs/src/clinics/dto/index.ts +4 -0
  571. package/nestjs/src/clinics/dto/update-clinic.dto.ts +133 -0
  572. package/nestjs/src/clinics/dto/update-meta-connection.dto.ts +22 -0
  573. package/nestjs/src/clinics/meta-connections.service.ts +210 -0
  574. package/nestjs/src/common/decorators/accessible-clinics.decorator.ts +9 -0
  575. package/nestjs/src/common/decorators/current-user.decorator.ts +10 -0
  576. package/nestjs/src/common/decorators/features.decorator.ts +7 -0
  577. package/nestjs/src/common/decorators/index.ts +4 -0
  578. package/nestjs/src/common/decorators/permissions.decorator.ts +13 -0
  579. package/nestjs/src/common/gateways/events.gateway.ts +157 -0
  580. package/nestjs/src/common/guards/clinic-access.guard.ts +40 -0
  581. package/nestjs/src/common/guards/features.guard.ts +38 -0
  582. package/nestjs/src/common/guards/index.ts +5 -0
  583. package/nestjs/src/common/guards/jwt-auth.guard.ts +54 -0
  584. package/nestjs/src/common/guards/permissions.guard.ts +50 -0
  585. package/nestjs/src/common/guards/superadmin.guard.ts +35 -0
  586. package/nestjs/src/common/interceptors/request-context.interceptor.ts +32 -0
  587. package/nestjs/src/common/interceptors/superadmin-tenant.interceptor.ts +42 -0
  588. package/nestjs/src/common/interfaces/jwt-payload.interface.ts +11 -0
  589. package/nestjs/src/config/config.module.ts +13 -0
  590. package/nestjs/src/database/database.module.ts +9 -0
  591. package/nestjs/src/database/prisma.service.ts +20 -0
  592. package/nestjs/src/database/tenant-context.ts +27 -0
  593. package/nestjs/src/google-calendar/google-calendar.controller.ts +259 -0
  594. package/nestjs/src/google-calendar/google-calendar.module.ts +10 -0
  595. package/nestjs/src/google-calendar/google-calendar.service.ts +811 -0
  596. package/nestjs/src/integrations/ai/ai.interface.ts +74 -0
  597. package/nestjs/src/integrations/ai/ai.module.ts +11 -0
  598. package/nestjs/src/integrations/ai/ai.service.ts +148 -0
  599. package/nestjs/src/integrations/ai/providers/anthropic.provider.ts +146 -0
  600. package/nestjs/src/integrations/ai/providers/openai.provider.ts +158 -0
  601. package/nestjs/src/integrations/elevenlabs/elevenlabs.module.ts +8 -0
  602. package/nestjs/src/integrations/elevenlabs/elevenlabs.service.ts +226 -0
  603. package/nestjs/src/integrations/encryption/encryption.module.ts +9 -0
  604. package/nestjs/src/integrations/encryption/encryption.service.ts +31 -0
  605. package/nestjs/src/integrations/image/image.module.ts +9 -0
  606. package/nestjs/src/integrations/image/image.service.ts +61 -0
  607. package/nestjs/src/integrations/meta-business/meta-business.module.ts +10 -0
  608. package/nestjs/src/integrations/meta-business/meta-instagram.service.ts +94 -0
  609. package/nestjs/src/integrations/meta-business/meta-webhook.service.ts +52 -0
  610. package/nestjs/src/integrations/meta-business/meta-whatsapp.service.ts +254 -0
  611. package/nestjs/src/integrations/minio/minio.module.ts +9 -0
  612. package/nestjs/src/integrations/minio/minio.service.ts +88 -0
  613. package/nestjs/src/integrations/netgsm/netgsm.module.ts +8 -0
  614. package/nestjs/src/integrations/netgsm/netgsm.service.ts +17 -0
  615. package/nestjs/src/integrations/tektippay/tektippay.module.ts +8 -0
  616. package/nestjs/src/integrations/tektippay/tektippay.service.ts +23 -0
  617. package/nestjs/src/leads/dto/index.ts +2 -0
  618. package/nestjs/src/leads/dto/query-leads.dto.ts +50 -0
  619. package/nestjs/src/leads/dto/update-lead.dto.ts +26 -0
  620. package/nestjs/src/leads/leads.controller.ts +184 -0
  621. package/nestjs/src/leads/leads.module.ts +10 -0
  622. package/nestjs/src/leads/leads.service.ts +375 -0
  623. package/nestjs/src/logging/logging.controller.ts +82 -0
  624. package/nestjs/src/logging/logging.module.ts +11 -0
  625. package/nestjs/src/logging/logging.service.ts +180 -0
  626. package/nestjs/src/main.ts +86 -0
  627. package/nestjs/src/outbound-campaigns/dto/create-campaign.dto.ts +47 -0
  628. package/nestjs/src/outbound-campaigns/dto/index.ts +3 -0
  629. package/nestjs/src/outbound-campaigns/dto/update-campaign.dto.ts +31 -0
  630. package/nestjs/src/outbound-campaigns/dto/upload-entries.dto.ts +35 -0
  631. package/nestjs/src/outbound-campaigns/outbound-campaigns.controller.ts +307 -0
  632. package/nestjs/src/outbound-campaigns/outbound-campaigns.module.ts +13 -0
  633. package/nestjs/src/outbound-campaigns/outbound-campaigns.service.ts +471 -0
  634. package/nestjs/src/outbound-campaigns/processors/campaign-dispatch.processor.ts +366 -0
  635. package/nestjs/src/patients/documents.service.ts +231 -0
  636. package/nestjs/src/patients/dto/create-examination.dto.ts +34 -0
  637. package/nestjs/src/patients/dto/create-note.dto.ts +14 -0
  638. package/nestjs/src/patients/dto/create-patient.dto.ts +86 -0
  639. package/nestjs/src/patients/dto/create-photo-set.dto.ts +32 -0
  640. package/nestjs/src/patients/dto/index.ts +10 -0
  641. package/nestjs/src/patients/dto/update-examination.dto.ts +29 -0
  642. package/nestjs/src/patients/dto/update-medical-history.dto.ts +47 -0
  643. package/nestjs/src/patients/dto/update-patient.dto.ts +87 -0
  644. package/nestjs/src/patients/dto/update-photo-set.dto.ts +28 -0
  645. package/nestjs/src/patients/dto/upload-document.dto.ts +31 -0
  646. package/nestjs/src/patients/dto/upload-photo.dto.ts +27 -0
  647. package/nestjs/src/patients/examinations.service.ts +271 -0
  648. package/nestjs/src/patients/medical-history.service.ts +149 -0
  649. package/nestjs/src/patients/notes.service.ts +172 -0
  650. package/nestjs/src/patients/patients.controller.ts +485 -0
  651. package/nestjs/src/patients/patients.module.ts +22 -0
  652. package/nestjs/src/patients/patients.service.ts +412 -0
  653. package/nestjs/src/patients/photo-sets.service.ts +389 -0
  654. package/nestjs/src/phone-numbers/dto/create-phone-number.dto.ts +57 -0
  655. package/nestjs/src/phone-numbers/dto/index.ts +3 -0
  656. package/nestjs/src/phone-numbers/dto/update-phone-number.dto.ts +38 -0
  657. package/nestjs/src/phone-numbers/dto/update-schedule.dto.ts +39 -0
  658. package/nestjs/src/phone-numbers/phone-numbers.controller.ts +125 -0
  659. package/nestjs/src/phone-numbers/phone-numbers.module.ts +10 -0
  660. package/nestjs/src/phone-numbers/phone-numbers.service.ts +209 -0
  661. package/nestjs/src/plans/dto/create-plan.dto.ts +70 -0
  662. package/nestjs/src/plans/dto/index.ts +2 -0
  663. package/nestjs/src/plans/dto/update-plan.dto.ts +80 -0
  664. package/nestjs/src/plans/plans.controller.ts +50 -0
  665. package/nestjs/src/plans/plans.module.ts +10 -0
  666. package/nestjs/src/plans/plans.service.ts +115 -0
  667. package/nestjs/src/platform/dto/create-tenant.dto.ts +36 -0
  668. package/nestjs/src/platform/dto/index.ts +2 -0
  669. package/nestjs/src/platform/dto/update-tenant-platform.dto.ts +44 -0
  670. package/nestjs/src/platform/platform.controller.ts +79 -0
  671. package/nestjs/src/platform/platform.module.ts +10 -0
  672. package/nestjs/src/platform/platform.service.ts +301 -0
  673. package/nestjs/src/queue/queue.module.ts +56 -0
  674. package/nestjs/src/redis/redis.module.ts +20 -0
  675. package/nestjs/src/tenants/dto/index.ts +1 -0
  676. package/nestjs/src/tenants/dto/update-tenant.dto.ts +15 -0
  677. package/nestjs/src/tenants/tenants.controller.ts +45 -0
  678. package/nestjs/src/tenants/tenants.module.ts +10 -0
  679. package/nestjs/src/tenants/tenants.service.ts +41 -0
  680. package/nestjs/src/tools/adapters/elevenlabs-http.adapter.ts +51 -0
  681. package/nestjs/src/tools/adapters/elevenlabs-ws.adapter.ts +59 -0
  682. package/nestjs/src/tools/handlers/calendar.tools.ts +441 -0
  683. package/nestjs/src/tools/handlers/notification.tools.ts +34 -0
  684. package/nestjs/src/tools/handlers/operator.tools.ts +303 -0
  685. package/nestjs/src/tools/handlers/patient.tools.ts +242 -0
  686. package/nestjs/src/tools/handlers/payment.tools.ts +43 -0
  687. package/nestjs/src/tools/handlers/validation.tools.ts +152 -0
  688. package/nestjs/src/tools/tool-registry.service.ts +221 -0
  689. package/nestjs/src/tools/tool.decorator.ts +16 -0
  690. package/nestjs/src/tools/tool.interfaces.ts +26 -0
  691. package/nestjs/src/tools/tools.module.ts +50 -0
  692. package/nestjs/src/treatments/dto/create-plan-item.dto.ts +27 -0
  693. package/nestjs/src/treatments/dto/create-treatment-plan.dto.ts +69 -0
  694. package/nestjs/src/treatments/dto/create-treatment.dto.ts +59 -0
  695. package/nestjs/src/treatments/dto/index.ts +6 -0
  696. package/nestjs/src/treatments/dto/update-plan-item.dto.ts +23 -0
  697. package/nestjs/src/treatments/dto/update-treatment-plan.dto.ts +22 -0
  698. package/nestjs/src/treatments/dto/update-treatment.dto.ts +70 -0
  699. package/nestjs/src/treatments/treatment-plans.service.ts +362 -0
  700. package/nestjs/src/treatments/treatments.controller.ts +265 -0
  701. package/nestjs/src/treatments/treatments.module.ts +14 -0
  702. package/nestjs/src/treatments/treatments.service.ts +165 -0
  703. package/nestjs/src/users/doctor-profiles.service.ts +202 -0
  704. package/nestjs/src/users/dto/index.ts +4 -0
  705. package/nestjs/src/users/dto/invite-user.dto.ts +52 -0
  706. package/nestjs/src/users/dto/update-clinic-assignments.dto.ts +9 -0
  707. package/nestjs/src/users/dto/update-doctor-profile.dto.ts +49 -0
  708. package/nestjs/src/users/dto/update-user.dto.ts +41 -0
  709. package/nestjs/src/users/users.controller.ts +142 -0
  710. package/nestjs/src/users/users.module.ts +11 -0
  711. package/nestjs/src/users/users.service.ts +250 -0
  712. package/nestjs/src/webhooks/elevenlabs-tool.controller.ts +66 -0
  713. package/nestjs/src/webhooks/elevenlabs-webhook.controller.ts +60 -0
  714. package/nestjs/src/webhooks/meta-webhook.controller.ts +178 -0
  715. package/nestjs/src/webhooks/processors/elevenlabs-webhook.processor.ts +28 -0
  716. package/nestjs/src/webhooks/webhooks.module.ts +17 -0
  717. package/nestjs/src/whatsapp/chat-context.service.ts +281 -0
  718. package/nestjs/src/whatsapp/dto/add-blacklist.dto.ts +13 -0
  719. package/nestjs/src/whatsapp/dto/index.ts +2 -0
  720. package/nestjs/src/whatsapp/dto/send-message.dto.ts +14 -0
  721. package/nestjs/src/whatsapp/listeners/meta-message.listener.ts +579 -0
  722. package/nestjs/src/whatsapp/meta-auth.controller.ts +268 -0
  723. package/nestjs/src/whatsapp/meta-instagram-auth.controller.ts +244 -0
  724. package/nestjs/src/whatsapp/operator.service.ts +692 -0
  725. package/nestjs/src/whatsapp/processors/cost-sweep.processor.ts +130 -0
  726. package/nestjs/src/whatsapp/processors/grace.processor.ts +191 -0
  727. package/nestjs/src/whatsapp/processors/message-buffer.processor.ts +138 -0
  728. package/nestjs/src/whatsapp/processors/message-cleanup.processor.ts +148 -0
  729. package/nestjs/src/whatsapp/processors/meta-token-refresh.processor.ts +114 -0
  730. package/nestjs/src/whatsapp/processors/operator-expiry.processor.ts +105 -0
  731. package/nestjs/src/whatsapp/processors/profile-update.processor.ts +234 -0
  732. package/nestjs/src/whatsapp/processors/session-cleanup.processor.ts +178 -0
  733. package/nestjs/src/whatsapp/processors/session-labels.processor.ts +248 -0
  734. package/nestjs/src/whatsapp/whatsapp-agent.service.ts +2506 -0
  735. package/nestjs/src/whatsapp/whatsapp-recovery.service.ts +117 -0
  736. package/nestjs/src/whatsapp/whatsapp.controller.ts +398 -0
  737. package/nestjs/src/whatsapp/whatsapp.module.ts +51 -0
  738. package/nestjs/src/whatsapp/whatsapp.service.ts +592 -0
  739. package/nestjs/test/app.e2e-spec.ts +25 -0
  740. package/nestjs/test/jest-e2e.json +9 -0
  741. package/nestjs/tsconfig.build.json +4 -0
  742. package/nestjs/tsconfig.json +25 -0
  743. package/nginx.example.conf +18 -0
  744. package/package.json +47 -0
  745. package/scripts/addRecipient.ts +48 -0
  746. package/scripts/listRecipients.ts +31 -0
  747. package/scripts/migrate-agent-ref.ts +86 -0
  748. package/scripts/migrate-sessions.ts +183 -0
  749. package/scripts/promote.ts +27 -0
  750. package/scripts/retrigger.ts +84 -0
  751. package/scripts/seed.ts +435 -0
  752. package/scripts/testSend.ts +63 -0
  753. package/src/app.ts +85 -0
  754. package/src/config/agentPrompts.json +19 -0
  755. package/src/config/database.ts +21 -0
  756. package/src/config/env.ts +94 -0
  757. package/src/config/index.ts +86 -0
  758. package/src/controllers/webhook.controller.ts +150 -0
  759. package/src/errors/index.ts +9 -0
  760. package/src/hooks/webhookValidator.ts +55 -0
  761. package/src/index.ts +68 -0
  762. package/src/migrations/001_session-architecture.ts +138 -0
  763. package/src/migrations/002_agent-ref.ts +65 -0
  764. package/src/migrations/003_shift-schedule.ts +55 -0
  765. package/src/migrations/004_invert-shift-to-clinic-hours.ts +30 -0
  766. package/src/migrations/005_composite-baileys-chat-ids.ts +60 -0
  767. package/src/migrations/migration.model.ts +27 -0
  768. package/src/migrations/migration.routes.ts +40 -0
  769. package/src/migrations/migration.runner.ts +112 -0
  770. package/src/models/ConversationClaim.ts +17 -0
  771. package/src/models/ConversationEvaluation.ts +91 -0
  772. package/src/models/Summary.ts +77 -0
  773. package/src/models/Transcription.ts +68 -0
  774. package/src/models/WhatsAppRecipient.ts +37 -0
  775. package/src/modules/admin/admin.routes.ts +2385 -0
  776. package/src/modules/admin/elevenlabs-test-chat.routes.ts +193 -0
  777. package/src/modules/admin/whatsapp-test-chat.routes.ts +244 -0
  778. package/src/modules/agents/agent.model.ts +93 -0
  779. package/src/modules/agents/agent.routes.ts +65 -0
  780. package/src/modules/appointment-validation/appointment-validation.model.ts +163 -0
  781. package/src/modules/appointment-validation/appointment-validation.routes.ts +275 -0
  782. package/src/modules/appointment-validation/appointment-validation.service.ts +1028 -0
  783. package/src/modules/auth/auth.model.ts +42 -0
  784. package/src/modules/auth/auth.routes.ts +199 -0
  785. package/src/modules/auth/auth.service.ts +210 -0
  786. package/src/modules/auth/refresh-token.model.ts +26 -0
  787. package/src/modules/billing/billing-alert.model.ts +28 -0
  788. package/src/modules/billing/billing-period-snapshot.model.ts +68 -0
  789. package/src/modules/billing/billing.model.ts +42 -0
  790. package/src/modules/billing/billing.routes.ts +67 -0
  791. package/src/modules/billing/billing.service.ts +562 -0
  792. package/src/modules/billing/payment.model.ts +42 -0
  793. package/src/modules/calls/call.model.ts +102 -0
  794. package/src/modules/calls/call.routes.ts +118 -0
  795. package/src/modules/campaigns/campaign.model.ts +111 -0
  796. package/src/modules/campaigns/campaign.routes.ts +402 -0
  797. package/src/modules/campaigns/campaign.service.ts +99 -0
  798. package/src/modules/google-calendar/google-calendar.routes.ts +31 -0
  799. package/src/modules/inbound-call/inbound-call-config.model.ts +49 -0
  800. package/src/modules/inbound-call/inbound-call.routes.ts +289 -0
  801. package/src/modules/leads/lead.model.ts +40 -0
  802. package/src/modules/leads/lead.routes.ts +246 -0
  803. package/src/modules/logs/log.model.ts +27 -0
  804. package/src/modules/logs/log.routes.ts +102 -0
  805. package/src/modules/plans/plan.model.ts +45 -0
  806. package/src/modules/surveys/survey.model.ts +70 -0
  807. package/src/modules/surveys/survey.routes.ts +187 -0
  808. package/src/modules/tenants/tenant.model.ts +181 -0
  809. package/src/modules/tenants/tenant.routes.ts +78 -0
  810. package/src/modules/users/user.routes.ts +126 -0
  811. package/src/modules/webhooks/elevenlabs-tool.routes.ts +94 -0
  812. package/src/modules/webhooks/elevenlabs-tool.service.ts +491 -0
  813. package/src/modules/webhooks/elevenlabs.routes.ts +34 -0
  814. package/src/modules/webhooks/elevenlabs.service.ts +565 -0
  815. package/src/modules/webhooks/unipile.routes.ts +917 -0
  816. package/src/modules/whatsapp/appointment.model.ts +47 -0
  817. package/src/modules/whatsapp/operator-request.model.ts +58 -0
  818. package/src/modules/whatsapp/stt-usage.model.ts +30 -0
  819. package/src/modules/whatsapp/whatsapp-chat.model.ts +39 -0
  820. package/src/modules/whatsapp/whatsapp-contact-profile.model.ts +41 -0
  821. package/src/modules/whatsapp/whatsapp-message.model.ts +41 -0
  822. package/src/modules/whatsapp/whatsapp-session.model.ts +60 -0
  823. package/src/modules/whatsapp/whatsapp.routes.ts +1435 -0
  824. package/src/plugins/cors.ts +7 -0
  825. package/src/plugins/jwt.ts +18 -0
  826. package/src/plugins/rawBody.ts +12 -0
  827. package/src/plugins/rbac.ts +24 -0
  828. package/src/routes/admin.routes.ts +208 -0
  829. package/src/routes/health.routes.ts +12 -0
  830. package/src/routes/webhook.routes.ts +12 -0
  831. package/src/services/ai/base.ai.ts +132 -0
  832. package/src/services/ai/gemini.service.ts +41 -0
  833. package/src/services/ai/index.ts +24 -0
  834. package/src/services/ai/openai.service.ts +48 -0
  835. package/src/services/elevenlabs.service.ts +532 -0
  836. package/src/services/google-calendar.service.ts +656 -0
  837. package/src/services/inbound-call-schedule.service.ts +174 -0
  838. package/src/services/netgsm.service.ts +128 -0
  839. package/src/services/unipile.service.ts +200 -0
  840. package/src/services/whatsapp-agent.service.ts +2479 -0
  841. package/src/services/whatsapp.service.ts +245 -0
  842. package/src/templates/index.ts +71 -0
  843. package/src/templates/receptionist.ts +44 -0
  844. package/src/templates/survey.ts +44 -0
  845. package/src/types/index.ts +218 -0
  846. package/src/utils/logger.ts +83 -0
  847. package/tsconfig.json +19 -0
  848. package/web/.dockerignore +4 -0
  849. package/web/Dockerfile +18 -0
  850. package/web/README.md +73 -0
  851. package/web/components.json +23 -0
  852. package/web/eslint.config.js +23 -0
  853. package/web/index.html +14 -0
  854. package/web/nginx.conf +18 -0
  855. package/web/package-lock.json +10292 -0
  856. package/web/package.json +48 -0
  857. package/web/public/favicon.ico +0 -0
  858. package/web/public/vite.svg +1 -0
  859. package/web/src/App.tsx +191 -0
  860. package/web/src/assets/react.svg +1 -0
  861. package/web/src/components/Layout.tsx +261 -0
  862. package/web/src/components/LeadConversation.tsx +251 -0
  863. package/web/src/components/ProtectedRoute.tsx +20 -0
  864. package/web/src/components/conversation-review/CardAudioPlayer.tsx +200 -0
  865. package/web/src/components/conversation-review/CardEvaluation.tsx +351 -0
  866. package/web/src/components/conversation-review/CardMetadata.tsx +44 -0
  867. package/web/src/components/conversation-review/ConversationCard.tsx +95 -0
  868. package/web/src/components/conversation-review/ReviewContainer.tsx +120 -0
  869. package/web/src/components/conversation-review/ReviewFilters.tsx +88 -0
  870. package/web/src/components/empty-state.tsx +15 -0
  871. package/web/src/components/page-header.tsx +19 -0
  872. package/web/src/components/page-loader.tsx +32 -0
  873. package/web/src/components/pagination.tsx +38 -0
  874. package/web/src/components/stat-card.tsx +27 -0
  875. package/web/src/components/status-badge.tsx +125 -0
  876. package/web/src/components/ui/alert.tsx +66 -0
  877. package/web/src/components/ui/badge.tsx +48 -0
  878. package/web/src/components/ui/button.tsx +64 -0
  879. package/web/src/components/ui/card.tsx +92 -0
  880. package/web/src/components/ui/checkbox.tsx +32 -0
  881. package/web/src/components/ui/dialog.tsx +158 -0
  882. package/web/src/components/ui/dropdown-menu.tsx +255 -0
  883. package/web/src/components/ui/input.tsx +21 -0
  884. package/web/src/components/ui/label.tsx +22 -0
  885. package/web/src/components/ui/progress.tsx +29 -0
  886. package/web/src/components/ui/select.tsx +188 -0
  887. package/web/src/components/ui/separator.tsx +28 -0
  888. package/web/src/components/ui/sheet.tsx +123 -0
  889. package/web/src/components/ui/skeleton.tsx +13 -0
  890. package/web/src/components/ui/sonner.tsx +35 -0
  891. package/web/src/components/ui/table.tsx +116 -0
  892. package/web/src/components/ui/tabs.tsx +89 -0
  893. package/web/src/components/ui/textarea.tsx +18 -0
  894. package/web/src/components/ui/tooltip.tsx +57 -0
  895. package/web/src/components/whatsapp/ChatDetail.tsx +417 -0
  896. package/web/src/components/whatsapp/ChatHeader.tsx +78 -0
  897. package/web/src/components/whatsapp/ChatList.tsx +107 -0
  898. package/web/src/components/whatsapp/ChatListItem.tsx +60 -0
  899. package/web/src/components/whatsapp/MessageBubble.tsx +46 -0
  900. package/web/src/components/whatsapp/MessageInput.tsx +63 -0
  901. package/web/src/components/whatsapp/MessageStream.tsx +135 -0
  902. package/web/src/components/whatsapp/SessionDivider.tsx +65 -0
  903. package/web/src/components/whatsapp/SessionHistory.tsx +119 -0
  904. package/web/src/components/whatsapp/settings/AiSettingsTab.tsx +268 -0
  905. package/web/src/components/whatsapp/settings/CalendarTab.tsx +339 -0
  906. package/web/src/components/whatsapp/settings/ConnectionTab.tsx +236 -0
  907. package/web/src/components/whatsapp/settings/NotificationsTab.tsx +109 -0
  908. package/web/src/components/whatsapp/settings/OperatorTab.tsx +303 -0
  909. package/web/src/contexts/AuthContext.tsx +103 -0
  910. package/web/src/index.css +130 -0
  911. package/web/src/lib/api.ts +92 -0
  912. package/web/src/lib/utils.ts +6 -0
  913. package/web/src/main.tsx +10 -0
  914. package/web/src/pages/AppointmentDetail.tsx +206 -0
  915. package/web/src/pages/AppointmentValidation.tsx +157 -0
  916. package/web/src/pages/AppointmentValidationDetail.tsx +617 -0
  917. package/web/src/pages/AppointmentValidationNew.tsx +1005 -0
  918. package/web/src/pages/Appointments.tsx +283 -0
  919. package/web/src/pages/Billing.tsx +126 -0
  920. package/web/src/pages/CallDetail.tsx +293 -0
  921. package/web/src/pages/CallSettings.tsx +313 -0
  922. package/web/src/pages/Calls.tsx +188 -0
  923. package/web/src/pages/CampaignDetail.tsx +216 -0
  924. package/web/src/pages/CampaignNew.tsx +277 -0
  925. package/web/src/pages/CampaignResults.tsx +185 -0
  926. package/web/src/pages/Campaigns.tsx +171 -0
  927. package/web/src/pages/Dashboard.tsx +336 -0
  928. package/web/src/pages/InboundSchedule.tsx +246 -0
  929. package/web/src/pages/LeadDetail.tsx +183 -0
  930. package/web/src/pages/Leads.tsx +258 -0
  931. package/web/src/pages/Login.tsx +99 -0
  932. package/web/src/pages/Register.tsx +129 -0
  933. package/web/src/pages/Settings.tsx +133 -0
  934. package/web/src/pages/SurveyDetail.tsx +232 -0
  935. package/web/src/pages/SurveyPreview.tsx +179 -0
  936. package/web/src/pages/Surveys.tsx +207 -0
  937. package/web/src/pages/Users.tsx +199 -0
  938. package/web/src/pages/WhatsApp.tsx +147 -0
  939. package/web/src/pages/WhatsAppSettings.tsx +215 -0
  940. package/web/src/pages/admin/AdminBaileysMessageLog.tsx +331 -0
  941. package/web/src/pages/admin/AdminBaileysRawMessages.tsx +318 -0
  942. package/web/src/pages/admin/AdminConversationReview.tsx +116 -0
  943. package/web/src/pages/admin/AdminCredits.tsx +467 -0
  944. package/web/src/pages/admin/AdminElevenLabsAgentDetail.tsx +332 -0
  945. package/web/src/pages/admin/AdminElevenLabsAgents.tsx +164 -0
  946. package/web/src/pages/admin/AdminElevenLabsBatchCallDetail.tsx +214 -0
  947. package/web/src/pages/admin/AdminElevenLabsBatchCalls.tsx +293 -0
  948. package/web/src/pages/admin/AdminElevenLabsConversationDetail.tsx +230 -0
  949. package/web/src/pages/admin/AdminElevenLabsConversations.tsx +228 -0
  950. package/web/src/pages/admin/AdminElevenLabsInboundCalls.tsx +293 -0
  951. package/web/src/pages/admin/AdminElevenLabsPhoneNumbers.tsx +506 -0
  952. package/web/src/pages/admin/AdminElevenLabsTestChat.tsx +258 -0
  953. package/web/src/pages/admin/AdminElevenLabsWebhooks.tsx +289 -0
  954. package/web/src/pages/admin/AdminEvaluationDashboard.tsx +520 -0
  955. package/web/src/pages/admin/AdminLeads.tsx +339 -0
  956. package/web/src/pages/admin/AdminLogs.tsx +283 -0
  957. package/web/src/pages/admin/AdminMigrations.tsx +247 -0
  958. package/web/src/pages/admin/AdminPlans.tsx +313 -0
  959. package/web/src/pages/admin/AdminSystem.tsx +391 -0
  960. package/web/src/pages/admin/AdminTenantBillableItems.tsx +464 -0
  961. package/web/src/pages/admin/AdminTenantDetail.tsx +1317 -0
  962. package/web/src/pages/admin/AdminTenants.tsx +274 -0
  963. package/web/src/pages/admin/AdminWhatsApp.tsx +618 -0
  964. package/web/src/pages/admin/AdminWhatsAppTestChat.tsx +328 -0
  965. package/web/src/types/index.ts +242 -0
  966. package/web/tsconfig.app.json +32 -0
  967. package/web/tsconfig.json +13 -0
  968. package/web/tsconfig.node.json +26 -0
  969. package/web/vite.config.ts +23 -0
@@ -0,0 +1,2385 @@
1
+ import mongoose from 'mongoose';
2
+ import { FastifyInstance } from 'fastify';
3
+ import Tenant, { AVAILABLE_FEATURES } from '../tenants/tenant.model';
4
+ import User from '../auth/auth.model';
5
+ import Agent from '../agents/agent.model';
6
+ import Call from '../calls/call.model';
7
+ import Campaign from '../campaigns/campaign.model';
8
+ import BillingEvent from '../billing/billing.model';
9
+ import { requireRole } from '../../plugins/rbac';
10
+ import { hashPassword } from '../auth/auth.service';
11
+ import Plan from '../plans/plan.model';
12
+ import Payment from '../billing/payment.model';
13
+ import elevenlabsService from '../../services/elevenlabs.service';
14
+ import { snapshotPeriod } from '../billing/billing.service';
15
+ import logger from '../../utils/logger';
16
+ import elevenlabsTestChatRoutes from './elevenlabs-test-chat.routes';
17
+ import whatsappTestChatRoutes from './whatsapp-test-chat.routes';
18
+ import migrationRoutes from '../../migrations/migration.routes';
19
+ import WhatsAppSession from '../whatsapp/whatsapp-session.model';
20
+ import WhatsAppMessage from '../whatsapp/whatsapp-message.model';
21
+ import WhatsAppChat from '../whatsapp/whatsapp-chat.model';
22
+ import WhatsAppContactProfile from '../whatsapp/whatsapp-contact-profile.model';
23
+ import whatsappAgentService from '../../services/whatsapp-agent.service';
24
+ import Lead from '../leads/lead.model';
25
+ import ConversationEvaluation from '../../models/ConversationEvaluation';
26
+ import ConversationClaim from '../../models/ConversationClaim';
27
+ import { Readable } from 'stream';
28
+
29
+ export default async function superAdminRoutes(fastify: FastifyInstance): Promise<void> {
30
+ fastify.addHook('preHandler', fastify.authenticate);
31
+ fastify.addHook('preHandler', requireRole('superadmin'));
32
+
33
+ // Register sub-routes
34
+ await fastify.register(elevenlabsTestChatRoutes, { prefix: '/elevenlabs/test-chat' });
35
+ await fastify.register(whatsappTestChatRoutes, { prefix: '/whatsapp/test-chat' });
36
+ await fastify.register(migrationRoutes, { prefix: '/migrations' });
37
+
38
+ // ─── Tenants ────────────────────────────────────────────────
39
+
40
+ // GET /admin/tenants — list all tenants with user count
41
+ fastify.get<{
42
+ Querystring: { page?: string; search?: string };
43
+ }>('/tenants', async (request) => {
44
+ const page = parseInt(request.query.page || '1', 10);
45
+ const limit = 20;
46
+ const skip = (page - 1) * limit;
47
+
48
+ const filter: Record<string, unknown> = { $or: [{ is_active: true }, { is_active: { $exists: false } }] };
49
+ if (request.query.search) {
50
+ filter.name = { $regex: request.query.search, $options: 'i' };
51
+ }
52
+
53
+ const [tenants, total] = await Promise.all([
54
+ Tenant.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(),
55
+ Tenant.countDocuments(filter)
56
+ ]);
57
+
58
+ // Attach user count per tenant
59
+ const tenantIds = tenants.map(t => t._id);
60
+ const userCounts = await User.aggregate([
61
+ { $match: { tenant_id: { $in: tenantIds } } },
62
+ { $group: { _id: '$tenant_id', count: { $sum: 1 } } }
63
+ ]);
64
+ const userCountMap = new Map(userCounts.map(u => [String(u._id), u.count]));
65
+
66
+ const enriched = tenants.map(t => ({
67
+ ...t,
68
+ user_count: userCountMap.get(String(t._id)) || 0
69
+ }));
70
+
71
+ return { tenants: enriched, total, page, totalPages: Math.ceil(total / limit) };
72
+ });
73
+
74
+ // POST /admin/tenants — create tenant manually
75
+ fastify.post('/tenants', async (request, reply) => {
76
+ const { name, plan, enabled_features } = request.body as {
77
+ name: string;
78
+ plan?: string;
79
+ enabled_features?: string[];
80
+ };
81
+
82
+ if (!name) {
83
+ return reply.status(400).send({ error: 'name is required' });
84
+ }
85
+
86
+ const tenant = await Tenant.create({
87
+ name,
88
+ plan: plan || 'free',
89
+ enabled_features: enabled_features || [],
90
+ settings: { kvkk_consent: false, notification_recipients: [], default_language: 'tr' }
91
+ });
92
+
93
+ return reply.status(201).send({ tenant });
94
+ });
95
+
96
+ // GET /admin/tenants/:id — full tenant detail
97
+ fastify.get('/tenants/:id', async (request, reply) => {
98
+ const { id } = request.params as { id: string };
99
+ const tenant = await Tenant.findById(id).lean();
100
+ if (!tenant) {
101
+ return reply.status(404).send({ error: 'Tenant not found' });
102
+ }
103
+
104
+ const [users, agents, callCount, campaignCount] = await Promise.all([
105
+ User.find({ tenant_id: id }).select('name email role createdAt').lean(),
106
+ Agent.find({ tenant_id: id }).lean(),
107
+ Call.countDocuments({ tenant_id: id }),
108
+ Campaign.countDocuments({ tenant_id: id })
109
+ ]);
110
+
111
+ return { tenant, users, agents, callCount, campaignCount };
112
+ });
113
+
114
+ // PUT /admin/tenants/:id — update tenant
115
+ fastify.put('/tenants/:id', async (request, reply) => {
116
+ const { id } = request.params as { id: string };
117
+ const { name, plan, enabled_features, settings } = request.body as {
118
+ name?: string;
119
+ plan?: string;
120
+ enabled_features?: string[];
121
+ settings?: Record<string, unknown>;
122
+ };
123
+
124
+ const tenant = await Tenant.findById(id);
125
+ if (!tenant) {
126
+ return reply.status(404).send({ error: 'Tenant not found' });
127
+ }
128
+
129
+ if (name) tenant.name = name;
130
+ if (plan) tenant.plan = plan;
131
+ if (enabled_features) {
132
+ tenant.enabled_features = enabled_features.filter(
133
+ f => (AVAILABLE_FEATURES as readonly string[]).includes(f)
134
+ ) as typeof tenant.enabled_features;
135
+ }
136
+ if (settings) {
137
+ const { whatsapp_agent, ...rest } = settings as Record<string, unknown>;
138
+ Object.assign(tenant.settings, rest);
139
+ if (whatsapp_agent && typeof whatsapp_agent === 'object') {
140
+ Object.assign(tenant.settings.whatsapp_agent, whatsapp_agent);
141
+ }
142
+ }
143
+
144
+ await tenant.save();
145
+ return { tenant };
146
+ });
147
+
148
+ // PUT /admin/tenants/:id/features — toggle features
149
+ fastify.put('/tenants/:id/features', async (request, reply) => {
150
+ const { id } = request.params as { id: string };
151
+ const { enabled_features } = request.body as { enabled_features: string[] };
152
+
153
+ const tenant = await Tenant.findById(id);
154
+ if (!tenant) {
155
+ return reply.status(404).send({ error: 'Tenant not found' });
156
+ }
157
+
158
+ tenant.enabled_features = enabled_features.filter(
159
+ f => (AVAILABLE_FEATURES as readonly string[]).includes(f)
160
+ ) as typeof tenant.enabled_features;
161
+
162
+ await tenant.save();
163
+ return { tenant };
164
+ });
165
+
166
+ // PUT /admin/tenants/:id/deactivate — soft delete (deactivate tenant)
167
+ fastify.put('/tenants/:id/deactivate', async (request, reply) => {
168
+ const { id } = request.params as { id: string };
169
+ const tenant = await Tenant.findById(id);
170
+ if (!tenant) return reply.status(404).send({ error: 'Tenant not found' });
171
+
172
+ tenant.is_active = false;
173
+ tenant.deleted_at = new Date();
174
+ await tenant.save();
175
+
176
+ logger.info('Tenant deactivated (soft delete)', { tenantId: id, name: tenant.name });
177
+ return { tenant };
178
+ });
179
+
180
+ // PUT /admin/tenants/:id/reactivate — undo soft delete
181
+ fastify.put('/tenants/:id/reactivate', async (request, reply) => {
182
+ const { id } = request.params as { id: string };
183
+ const tenant = await Tenant.findById(id);
184
+ if (!tenant) return reply.status(404).send({ error: 'Tenant not found' });
185
+
186
+ tenant.is_active = true;
187
+ tenant.deleted_at = null;
188
+ await tenant.save();
189
+
190
+ logger.info('Tenant reactivated', { tenantId: id, name: tenant.name });
191
+ return { tenant };
192
+ });
193
+
194
+ // DELETE /admin/tenants/:id — hard delete (tombstones data, mangles unique fields)
195
+ fastify.delete('/tenants/:id', async (request, reply) => {
196
+ const { id } = request.params as { id: string };
197
+ const tenant = await Tenant.findById(id);
198
+ if (!tenant) return reply.status(404).send({ error: 'Tenant not found' });
199
+
200
+ if (tenant.is_active) {
201
+ return reply.status(400).send({ error: 'Tenant must be deactivated before permanent deletion' });
202
+ }
203
+
204
+ const suffix = `___deleted_${Date.now()}`;
205
+ const tenantOid = new mongoose.Types.ObjectId(id);
206
+
207
+ // Mangle unique fields to avoid conflicts, mark as deleted
208
+ await Promise.all([
209
+ // Mangle user emails so they can re-register
210
+ User.updateMany(
211
+ { tenant_id: tenantOid },
212
+ [{ $set: { email: { $concat: ['$email', suffix] }, is_active: false } }],
213
+ ),
214
+ // Deactivate agents
215
+ Agent.updateMany(
216
+ { tenant_id: tenantOid },
217
+ { $set: { is_active: false } },
218
+ ),
219
+ // Mangle tenant name
220
+ Tenant.updateOne(
221
+ { _id: tenantOid },
222
+ { $set: { name: `${tenant.name}${suffix}`, is_active: false, deleted_at: new Date() } },
223
+ ),
224
+ ]);
225
+
226
+ logger.info('Tenant hard deleted (tombstoned)', { tenantId: id, name: tenant.name, suffix });
227
+ return { deleted: true };
228
+ });
229
+
230
+ // ─── User Creation for Tenant ──────────────────────────────
231
+
232
+ // POST /admin/tenants/:id/users — create user for a tenant
233
+ fastify.post('/tenants/:id/users', async (request, reply) => {
234
+ const { id } = request.params as { id: string };
235
+ const { email, password, name, role } = request.body as {
236
+ email: string;
237
+ password: string;
238
+ name: string;
239
+ role?: string;
240
+ };
241
+
242
+ if (!email || !password || !name) {
243
+ return reply.status(400).send({ error: 'email, password, and name are required' });
244
+ }
245
+
246
+ const tenant = await Tenant.findById(id);
247
+ if (!tenant) {
248
+ return reply.status(404).send({ error: 'Tenant not found' });
249
+ }
250
+
251
+ const existing = await User.findOne({ email: email.toLowerCase() });
252
+ if (existing) {
253
+ return reply.status(409).send({ error: 'Bu e-posta zaten kullaniliyor' });
254
+ }
255
+
256
+ const password_hash = await hashPassword(password);
257
+ const user = await User.create({
258
+ tenant_id: id,
259
+ email: email.toLowerCase(),
260
+ password_hash,
261
+ name,
262
+ role: role || 'admin'
263
+ });
264
+
265
+ return reply.status(201).send({
266
+ user: { _id: user._id, name: user.name, email: user.email, role: user.role, createdAt: user.createdAt }
267
+ });
268
+ });
269
+
270
+ // ─── Agent Provisioning ─────────────────────────────────────
271
+
272
+ // GET /admin/elevenlabs/available-agents — EL agents not yet assigned to any tenant
273
+ fastify.get('/elevenlabs/available-agents', async () => {
274
+ // Get all ElevenLabs agents
275
+ const elAgents = await elevenlabsService.listAgents({ pageSize: 100 });
276
+ // Get all already-assigned ElevenLabs agent IDs
277
+ const assignedIds = await Agent.distinct('elevenlabs_agent_id');
278
+ const assignedSet = new Set(assignedIds as string[]);
279
+
280
+ const available = elAgents.agents
281
+ .filter(a => !assignedSet.has(a.agentId))
282
+ .map(a => ({
283
+ agent_id: a.agentId,
284
+ name: a.name,
285
+ }));
286
+
287
+ return { agents: available };
288
+ });
289
+
290
+ // POST /admin/agents — provision agent for a tenant
291
+ fastify.post('/agents', async (request, reply) => {
292
+ const {
293
+ tenant_id, phone_number, elevenlabs_agent_id, direction,
294
+ post_call_processing
295
+ } = request.body as {
296
+ tenant_id: string;
297
+ phone_number: string;
298
+ elevenlabs_agent_id: string;
299
+ direction?: string;
300
+ post_call_processing?: Record<string, unknown>;
301
+ };
302
+
303
+ if (!tenant_id || !phone_number || !elevenlabs_agent_id) {
304
+ return reply.status(400).send({
305
+ error: 'tenant_id, phone_number, and elevenlabs_agent_id are required'
306
+ });
307
+ }
308
+
309
+ const tenant = await Tenant.findById(tenant_id);
310
+ if (!tenant) {
311
+ return reply.status(404).send({ error: 'Tenant not found' });
312
+ }
313
+
314
+ // Prevent duplicate assignment within the same tenant
315
+ const existing = await Agent.findOne({ elevenlabs_agent_id, tenant_id });
316
+ if (existing) {
317
+ return reply.status(409).send({ error: 'Bu ElevenLabs agent zaten bu tenanta atanmis' });
318
+ }
319
+
320
+ const agent = await Agent.create({
321
+ tenant_id,
322
+ phone_number,
323
+ elevenlabs_agent_id,
324
+ direction: direction && ['inbound', 'outbound', 'both'].includes(direction) ? direction : 'inbound',
325
+ agent_template: 'custom',
326
+ config: {
327
+ voice_id: 'default',
328
+ language: 'tr',
329
+ greeting: '',
330
+ template_data: {}
331
+ },
332
+ post_call_processing: {
333
+ strategy: 'summarize',
334
+ llm_provider: (post_call_processing?.llm_provider as string) || 'openai',
335
+ whatsapp_notify: true,
336
+ notification_template: 'call_summary',
337
+ summary_prompt: (post_call_processing?.summary_prompt as string) || '',
338
+ system_message: '',
339
+ whatsapp_message_template: (post_call_processing?.whatsapp_message_template as string) || ''
340
+ },
341
+ is_active: true
342
+ });
343
+
344
+ return reply.status(201).send({ agent });
345
+ });
346
+
347
+ // PUT /admin/agents/:id — update agent (superadmin)
348
+ fastify.put('/agents/:id', async (request, reply) => {
349
+ const { id } = request.params as { id: string };
350
+ const { phone_number, direction, is_active, post_call_processing } = request.body as {
351
+ phone_number?: string;
352
+ direction?: string;
353
+ is_active?: boolean;
354
+ post_call_processing?: { summary_prompt?: string; whatsapp_message_template?: string };
355
+ };
356
+
357
+ const agent = await Agent.findById(id);
358
+ if (!agent) {
359
+ return reply.status(404).send({ error: 'Agent not found' });
360
+ }
361
+
362
+ if (phone_number !== undefined) agent.phone_number = phone_number;
363
+ if (direction && ['inbound', 'outbound', 'both'].includes(direction)) agent.direction = direction as any;
364
+ if (is_active !== undefined) agent.is_active = is_active;
365
+ if (post_call_processing) {
366
+ if (post_call_processing.summary_prompt !== undefined) {
367
+ agent.post_call_processing.summary_prompt = post_call_processing.summary_prompt;
368
+ }
369
+ if (post_call_processing.whatsapp_message_template !== undefined) {
370
+ agent.post_call_processing.whatsapp_message_template = post_call_processing.whatsapp_message_template;
371
+ }
372
+ }
373
+
374
+ await agent.save();
375
+ return { agent };
376
+ });
377
+
378
+ // ─── System Health ──────────────────────────────────────────
379
+
380
+ // GET /admin/system/health
381
+ fastify.get('/system/health', async () => {
382
+ const now = new Date();
383
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
384
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
385
+ const sevenDaysAgo = new Date(now);
386
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6);
387
+ sevenDaysAgo.setHours(0, 0, 0, 0);
388
+
389
+ const [
390
+ tenantCount,
391
+ userCount,
392
+ agentCount,
393
+ totalCalls,
394
+ todayCalls,
395
+ activeCampaigns,
396
+ monthlyRevenue,
397
+ callStatusRows,
398
+ dailyTrendRows
399
+ ] = await Promise.all([
400
+ Tenant.countDocuments(),
401
+ User.countDocuments(),
402
+ Agent.countDocuments(),
403
+ Call.countDocuments(),
404
+ Call.countDocuments({ createdAt: { $gte: todayStart } }),
405
+ Campaign.countDocuments({ status: { $in: ['running', 'scheduled'] } }),
406
+ BillingEvent.aggregate([
407
+ { $match: { type: 'call_charge', createdAt: { $gte: monthStart } } },
408
+ { $group: { _id: null, total: { $sum: '$amount' } } }
409
+ ]).then(r => r[0]?.total || 0),
410
+ // Call status breakdown this month
411
+ Call.aggregate([
412
+ { $match: { createdAt: { $gte: monthStart } } },
413
+ { $group: { _id: '$status', count: { $sum: 1 } } }
414
+ ]),
415
+ // Daily trend last 7 days
416
+ Call.aggregate([
417
+ { $match: { createdAt: { $gte: sevenDaysAgo } } },
418
+ { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, count: { $sum: 1 } } },
419
+ { $sort: { _id: 1 } }
420
+ ])
421
+ ]);
422
+
423
+ // Build status breakdown map
424
+ const callStatusBreakdown: Record<string, number> = { completed: 0, failed: 0, no_answer: 0, voicemail: 0, transferred: 0 };
425
+ callStatusRows.forEach((r: { _id: string; count: number }) => { callStatusBreakdown[r._id] = r.count; });
426
+
427
+ // Fill 7-day trend with zeros for missing days
428
+ const dailyTrend: { date: string; count: number }[] = [];
429
+ for (let i = 6; i >= 0; i--) {
430
+ const d = new Date(now);
431
+ d.setDate(d.getDate() - i);
432
+ const key = d.toISOString().slice(0, 10);
433
+ const found = dailyTrendRows.find((r: { _id: string }) => r._id === key);
434
+ dailyTrend.push({ date: key, count: found?.count || 0 });
435
+ }
436
+
437
+ return {
438
+ status: 'ok',
439
+ uptime: process.uptime(),
440
+ stats: {
441
+ tenants: tenantCount,
442
+ users: userCount,
443
+ agents: agentCount,
444
+ total_calls: totalCalls,
445
+ today_calls: todayCalls,
446
+ active_campaigns: activeCampaigns,
447
+ monthly_revenue: monthlyRevenue
448
+ },
449
+ call_status_breakdown: callStatusBreakdown,
450
+ daily_trend: dailyTrend,
451
+ system: {
452
+ memory: process.memoryUsage(),
453
+ node_version: process.version,
454
+ db_state: mongoose.connection.readyState
455
+ }
456
+ };
457
+ });
458
+
459
+ // GET /admin/system/usage — global usage per tenant with quota info
460
+ fastify.get('/system/usage', async () => {
461
+ const monthStart = new Date();
462
+ monthStart.setDate(1);
463
+ monthStart.setHours(0, 0, 0, 0);
464
+ const now = new Date();
465
+
466
+ const [usagePerTenant, allPlans, activeTopUps] = await Promise.all([
467
+ Call.aggregate([
468
+ { $match: { createdAt: { $gte: monthStart } } },
469
+ {
470
+ $group: {
471
+ _id: '$tenant_id',
472
+ calls: { $sum: 1 },
473
+ duration: { $sum: '$duration_seconds' },
474
+ completed: { $sum: { $cond: [{ $eq: ['$status', 'completed'] }, 1, 0] } },
475
+ failed: { $sum: { $cond: [{ $eq: ['$status', 'failed'] }, 1, 0] } },
476
+ no_answer: { $sum: { $cond: [{ $eq: ['$status', 'no_answer'] }, 1, 0] } }
477
+ }
478
+ },
479
+ {
480
+ $lookup: {
481
+ from: 'tenants',
482
+ localField: '_id',
483
+ foreignField: '_id',
484
+ as: 'tenant'
485
+ }
486
+ },
487
+ { $unwind: '$tenant' },
488
+ {
489
+ $project: {
490
+ tenant_id: '$_id',
491
+ tenant_name: '$tenant.name',
492
+ plan: '$tenant.plan',
493
+ calls: 1,
494
+ duration: 1,
495
+ completed: 1,
496
+ failed: 1,
497
+ no_answer: 1
498
+ }
499
+ },
500
+ { $sort: { calls: -1 } }
501
+ ]),
502
+ Plan.find().lean(),
503
+ Payment.aggregate([
504
+ {
505
+ $match: {
506
+ type: 'top_up',
507
+ period_start: { $lte: now },
508
+ period_end: { $gte: monthStart }
509
+ }
510
+ },
511
+ { $group: { _id: '$tenant_id', extra_minutes: { $sum: '$extra_minutes' } } }
512
+ ])
513
+ ]);
514
+
515
+ const planMap = new Map(allPlans.map(p => [p.slug, p.included_minutes]));
516
+ const topUpMap = new Map(activeTopUps.map((t: { _id: mongoose.Types.ObjectId; extra_minutes: number }) => [String(t._id), t.extra_minutes]));
517
+
518
+ const enriched = usagePerTenant.map((u: any) => {
519
+ const planMinutes = planMap.get(u.plan) || 0;
520
+ const topUpMinutes = topUpMap.get(String(u.tenant_id)) || 0;
521
+ const totalQuota = planMinutes + topUpMinutes;
522
+ const usedMinutes = Math.ceil(u.duration / 60);
523
+ const pct = totalQuota > 0 ? Math.round((usedMinutes / totalQuota) * 100) : 0;
524
+
525
+ return {
526
+ ...u,
527
+ plan_minutes: planMinutes,
528
+ top_up_minutes: topUpMinutes,
529
+ total_quota: totalQuota,
530
+ used_minutes: usedMinutes,
531
+ quota_percentage: Math.min(pct, 100),
532
+ over_quota: usedMinutes > totalQuota
533
+ };
534
+ });
535
+
536
+ return { usage: enriched };
537
+ });
538
+
539
+ // GET /admin/templates — list agent templates
540
+ fastify.get('/templates', async () => {
541
+ return {
542
+ templates: [
543
+ { name: 'receptionist', direction: 'inbound', description: 'AI resepsiyonist — cagri karsilama, SSS, randevu' },
544
+ { name: 'survey', direction: 'outbound', description: 'AI anketci — yapilandirilmis soru-cevap' },
545
+ { name: 'custom', direction: 'both', description: 'Ozel prompt — tenant kendi promptunu yazar' }
546
+ ]
547
+ };
548
+ });
549
+
550
+ // ─── Plans ─────────────────────────────────────────────────
551
+
552
+ // GET /admin/plans — list all plans
553
+ fastify.get('/plans', async () => {
554
+ const plans = await Plan.find().sort({ sort_order: 1 }).lean();
555
+ return { plans };
556
+ });
557
+
558
+ // POST /admin/plans — create plan
559
+ fastify.post('/plans', async (request, reply) => {
560
+ const { name, slug, price, included_minutes, overage_rate, language_support, sort_order,
561
+ included_inbound_minutes, included_inbound_calls, included_outbound_minutes, included_outbound_calls, included_wa_chats,
562
+ } = request.body as {
563
+ name: string;
564
+ slug: string;
565
+ price: number;
566
+ included_minutes: number;
567
+ overage_rate: number;
568
+ language_support?: string;
569
+ sort_order?: number;
570
+ included_inbound_minutes?: number;
571
+ included_inbound_calls?: number;
572
+ included_outbound_minutes?: number;
573
+ included_outbound_calls?: number;
574
+ included_wa_chats?: number;
575
+ };
576
+
577
+ if (!name || !slug || price == null || included_minutes == null || overage_rate == null) {
578
+ return reply.status(400).send({ error: 'name, slug, price, included_minutes, overage_rate are required' });
579
+ }
580
+
581
+ const existing = await Plan.findOne({ slug });
582
+ if (existing) {
583
+ return reply.status(409).send({ error: 'Bu slug zaten kullaniliyor' });
584
+ }
585
+
586
+ const plan = await Plan.create({
587
+ name, slug, price, included_minutes, overage_rate,
588
+ included_inbound_minutes: included_inbound_minutes ?? 0,
589
+ included_inbound_calls: included_inbound_calls ?? 0,
590
+ included_outbound_minutes: included_outbound_minutes ?? 0,
591
+ included_outbound_calls: included_outbound_calls ?? 0,
592
+ included_wa_chats: included_wa_chats ?? 0,
593
+ language_support: language_support || 'single',
594
+ sort_order: sort_order ?? 0
595
+ });
596
+
597
+ return reply.status(201).send({ plan });
598
+ });
599
+
600
+ // PUT /admin/plans/:id — update plan
601
+ fastify.put('/plans/:id', async (request, reply) => {
602
+ const { id } = request.params as { id: string };
603
+ const updates = request.body as Record<string, unknown>;
604
+
605
+ const plan = await Plan.findById(id);
606
+ if (!plan) {
607
+ return reply.status(404).send({ error: 'Plan not found' });
608
+ }
609
+
610
+ const allowed = ['name', 'slug', 'price', 'included_minutes', 'overage_rate', 'included_inbound_minutes', 'included_inbound_calls', 'included_outbound_minutes', 'included_outbound_calls', 'included_wa_chats', 'language_support', 'is_active', 'sort_order'];
611
+ for (const key of allowed) {
612
+ if (updates[key] !== undefined) {
613
+ (plan as any)[key] = updates[key];
614
+ }
615
+ }
616
+
617
+ await plan.save();
618
+ return { plan };
619
+ });
620
+
621
+ // POST /admin/plans/seed — seed default plans
622
+ fastify.post('/plans/seed', async (request, reply) => {
623
+ const count = await Plan.countDocuments();
624
+ if (count > 0) {
625
+ return reply.status(409).send({ error: 'Planlar zaten mevcut', count });
626
+ }
627
+
628
+ const defaults = [
629
+ { name: 'Ucretsiz', slug: 'free', price: 0, included_minutes: 30, overage_rate: 0, language_support: 'single', sort_order: 0 },
630
+ { name: 'Mini Paket', slug: 'mini', price: 2999, included_minutes: 100, overage_rate: 34.99, language_support: 'single', sort_order: 1 },
631
+ { name: 'Standart Paket', slug: 'standart', price: 4999, included_minutes: 200, overage_rate: 29.99, language_support: 'single', sort_order: 2 },
632
+ { name: 'Pro Paket', slug: 'pro', price: 6999, included_minutes: 300, overage_rate: 24.99, language_support: 'dual', sort_order: 3 },
633
+ { name: 'Pro Max Paket', slug: 'pro-max', price: 9999, included_minutes: 500, overage_rate: 19.99, language_support: 'multi', sort_order: 4 },
634
+ ];
635
+
636
+ const plans = await Plan.insertMany(defaults);
637
+ return reply.status(201).send({ plans });
638
+ });
639
+
640
+ // ─── Payments ───────────────────────────────────────────────
641
+
642
+ // GET /admin/payments — list all payments (filterable by tenant_id)
643
+ fastify.get<{
644
+ Querystring: { page?: string; tenant_id?: string };
645
+ }>('/payments', async (request) => {
646
+ const page = parseInt(request.query.page || '1', 10);
647
+ const limit = 20;
648
+ const skip = (page - 1) * limit;
649
+
650
+ const filter: Record<string, unknown> = {};
651
+ if (request.query.tenant_id) {
652
+ filter.tenant_id = request.query.tenant_id;
653
+ }
654
+
655
+ const [payments, total] = await Promise.all([
656
+ Payment.find(filter).sort({ period_start: -1 }).skip(skip).limit(limit).lean(),
657
+ Payment.countDocuments(filter)
658
+ ]);
659
+
660
+ return { payments, total, page, totalPages: Math.ceil(total / limit) };
661
+ });
662
+
663
+ // GET /admin/tenants/:id/payments — payments for a specific tenant
664
+ fastify.get('/tenants/:id/payments', async (request) => {
665
+ const { id } = request.params as { id: string };
666
+ const payments = await Payment.find({ tenant_id: id }).sort({ period_start: -1 }).lean();
667
+ return { payments };
668
+ });
669
+
670
+ // GET /admin/tenants/:id/billable-items — calls & AI WhatsApp sessions as billable objects
671
+ fastify.get<{
672
+ Params: { id: string };
673
+ Querystring: { page_calls?: string; page_chats?: string; page_sessions?: string; from?: string; to?: string };
674
+ }>('/tenants/:id/billable-items', async (request, reply) => {
675
+ const { id } = request.params;
676
+
677
+ const tenant = await Tenant.findById(id).lean();
678
+ if (!tenant) {
679
+ return reply.status(404).send({ error: 'Tenant not found' });
680
+ }
681
+
682
+ const pageCalls = Math.max(1, parseInt(request.query.page_calls || '1', 10));
683
+ const pageChats = Math.max(1, parseInt(request.query.page_chats || (request.query as Record<string, string>).page_sessions || '1', 10));
684
+ const limit = 20;
685
+ const skipCalls = (pageCalls - 1) * limit;
686
+ const skipChats = (pageChats - 1) * limit;
687
+
688
+ const now = new Date();
689
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
690
+ const from = request.query.from ? new Date(request.query.from) : monthStart;
691
+ const to = request.query.to ? new Date(request.query.to) : now;
692
+
693
+ if (isNaN(from.getTime()) || isNaN(to.getTime())) {
694
+ return reply.status(400).send({ error: 'Invalid date format for from/to' });
695
+ }
696
+
697
+ const tenantOid = new mongoose.Types.ObjectId(id);
698
+
699
+ const [callItems, callSummaryRows, sessionResult] = await Promise.all([
700
+ // Q1: Paginated calls
701
+ Call.find({ tenant_id: id, createdAt: { $gte: from, $lte: to } })
702
+ .select('direction caller_number callee_number duration_seconds status cost createdAt agent_id conversation_id')
703
+ .sort({ createdAt: -1 })
704
+ .skip(skipCalls)
705
+ .limit(limit)
706
+ .lean(),
707
+
708
+ // Q2: Call summary (count + status breakdown + total duration + EL cost)
709
+ Call.aggregate([
710
+ { $match: { tenant_id: tenantOid, createdAt: { $gte: from, $lte: to } } },
711
+ {
712
+ $group: {
713
+ _id: null,
714
+ total: { $sum: 1 },
715
+ total_duration: { $sum: '$duration_seconds' },
716
+ total_cost: { $sum: { $ifNull: ['$cost', 0] } },
717
+ completed: { $sum: { $cond: [{ $eq: ['$status', 'completed'] }, 1, 0] } },
718
+ failed: { $sum: { $cond: [{ $eq: ['$status', 'failed'] }, 1, 0] } },
719
+ no_answer: { $sum: { $cond: [{ $eq: ['$status', 'no_answer'] }, 1, 0] } },
720
+ voicemail: { $sum: { $cond: [{ $eq: ['$status', 'voicemail'] }, 1, 0] } },
721
+ transferred: { $sum: { $cond: [{ $eq: ['$status', 'transferred'] }, 1, 0] } },
722
+ },
723
+ },
724
+ ]),
725
+
726
+ // Q3: Billable WA chats (AI answered at least once in date range) + pagination
727
+ WhatsAppChat.aggregate([
728
+ { $match: { tenant_id: tenantOid } },
729
+ // Single messages lookup: count total, AI messages, and collect session_ids
730
+ {
731
+ $lookup: {
732
+ from: 'whatsappmessages',
733
+ let: { cid: '$_id' },
734
+ pipeline: [
735
+ { $match: { $expr: { $eq: ['$chat_id', '$$cid'] }, createdAt: { $gte: from, $lte: to } } },
736
+ {
737
+ $group: {
738
+ _id: null,
739
+ total: { $sum: 1 },
740
+ ai_count: { $sum: { $cond: [{ $eq: ['$sender_name', 'AI'] }, 1, 0] } },
741
+ session_ids: { $addToSet: '$session_id' },
742
+ },
743
+ },
744
+ ],
745
+ as: '_messages',
746
+ },
747
+ },
748
+ {
749
+ $addFields: {
750
+ ai_message_count: { $ifNull: [{ $arrayElemAt: ['$_messages.ai_count', 0] }, 0] },
751
+ period_message_count: { $ifNull: [{ $arrayElemAt: ['$_messages.total', 0] }, 0] },
752
+ _session_ids: { $ifNull: [{ $arrayElemAt: ['$_messages.session_ids', 0] }, []] },
753
+ },
754
+ },
755
+ { $match: { ai_message_count: { $gt: 0 } } },
756
+ // Lookup sessions by actual message activity (not started_at) for EL cost
757
+ {
758
+ $lookup: {
759
+ from: 'whatsappsessions',
760
+ let: { sids: '$_session_ids' },
761
+ pipeline: [
762
+ { $match: { $expr: { $in: ['$_id', '$$sids'] } } },
763
+ {
764
+ $group: {
765
+ _id: null,
766
+ count: { $sum: 1 },
767
+ total_cost: { $sum: { $ifNull: ['$elevenlabs_cost', 0] } },
768
+ },
769
+ },
770
+ ],
771
+ as: '_sessions',
772
+ },
773
+ },
774
+ {
775
+ $addFields: {
776
+ session_count: { $ifNull: [{ $arrayElemAt: ['$_sessions.count', 0] }, 0] },
777
+ elevenlabs_cost: { $ifNull: [{ $arrayElemAt: ['$_sessions.total_cost', 0] }, 0] },
778
+ },
779
+ },
780
+ {
781
+ $project: {
782
+ contact_name: 1, contact_phone: 1,
783
+ ai_message_count: 1, period_message_count: 1,
784
+ session_count: 1, elevenlabs_cost: 1,
785
+ last_message_at: 1, createdAt: 1,
786
+ },
787
+ },
788
+ { $sort: { last_message_at: -1 } },
789
+ {
790
+ $facet: {
791
+ data: [{ $skip: skipChats }, { $limit: limit }],
792
+ count: [{ $count: 'total' }],
793
+ summary: [{
794
+ $group: {
795
+ _id: null,
796
+ total_messages: { $sum: '$period_message_count' },
797
+ total_ai_messages: { $sum: '$ai_message_count' },
798
+ total_elevenlabs_cost: { $sum: '$elevenlabs_cost' },
799
+ total_sessions: { $sum: '$session_count' },
800
+ },
801
+ }],
802
+ },
803
+ },
804
+ ]),
805
+ ]);
806
+
807
+ const cs = callSummaryRows[0] || {
808
+ total: 0, total_duration: 0, total_cost: 0,
809
+ completed: 0, failed: 0, no_answer: 0, voicemail: 0, transferred: 0,
810
+ };
811
+ const chatData = sessionResult[0]?.data || [];
812
+ const totalBillableChats = sessionResult[0]?.count?.[0]?.total || 0;
813
+ const ss = sessionResult[0]?.summary?.[0] || {
814
+ total_messages: 0, total_ai_messages: 0, total_elevenlabs_cost: 0, total_sessions: 0,
815
+ };
816
+
817
+ return {
818
+ summary: {
819
+ total_calls: cs.total,
820
+ total_call_duration_seconds: cs.total_duration,
821
+ total_elevenlabs_cost: cs.total_cost,
822
+ calls_by_status: {
823
+ completed: cs.completed,
824
+ failed: cs.failed,
825
+ no_answer: cs.no_answer,
826
+ voicemail: cs.voicemail,
827
+ transferred: cs.transferred,
828
+ },
829
+ total_billable_chats: totalBillableChats,
830
+ total_chat_sessions: ss.total_sessions,
831
+ total_chat_messages: ss.total_messages,
832
+ total_chat_ai_messages: ss.total_ai_messages,
833
+ total_chat_elevenlabs_cost: ss.total_elevenlabs_cost,
834
+ date_range: { from: from.toISOString(), to: to.toISOString() },
835
+ },
836
+ calls: {
837
+ items: callItems,
838
+ total: cs.total,
839
+ page: pageCalls,
840
+ totalPages: Math.ceil(cs.total / limit),
841
+ },
842
+ whatsapp_chats: {
843
+ items: chatData,
844
+ total: totalBillableChats,
845
+ page: pageChats,
846
+ totalPages: Math.ceil(totalBillableChats / limit),
847
+ },
848
+ };
849
+ });
850
+
851
+ // POST /admin/tenants/:id/sync-whatsapp-costs — fetch ElevenLabs credits for sessions missing cost
852
+ fastify.post<{ Params: { id: string } }>('/tenants/:id/sync-whatsapp-costs', async (request, reply) => {
853
+ const { id } = request.params;
854
+
855
+ const tenant = await Tenant.findById(id).lean();
856
+ if (!tenant) {
857
+ return reply.status(404).send({ error: 'Tenant not found' });
858
+ }
859
+
860
+ const sessions = await WhatsAppSession.find({
861
+ tenant_id: new mongoose.Types.ObjectId(id),
862
+ elevenlabs_conversation_id: { $ne: null },
863
+ elevenlabs_cost: null,
864
+ }).lean();
865
+
866
+ let updated = 0;
867
+ let failed = 0;
868
+ for (const session of sessions) {
869
+ try {
870
+ const conv = await elevenlabsService.getConversation(session.elevenlabs_conversation_id!);
871
+ const cost = (conv as unknown as { metadata?: { cost?: number } }).metadata?.cost;
872
+ if (cost != null) {
873
+ await WhatsAppSession.updateOne({ _id: session._id }, { elevenlabs_cost: cost });
874
+ updated++;
875
+ }
876
+ } catch (err) {
877
+ const status = (err as { statusCode?: number }).statusCode
878
+ || (err as { status?: number }).status;
879
+ if (status === 404) {
880
+ await WhatsAppSession.updateOne({ _id: session._id }, { elevenlabs_cost: 0 });
881
+ updated++;
882
+ } else {
883
+ failed++;
884
+ }
885
+ }
886
+ }
887
+
888
+ return { total: sessions.length, updated, failed };
889
+ });
890
+
891
+ // POST /admin/payments — record a new payment
892
+ fastify.post('/payments', async (request, reply) => {
893
+ const { tenant_id, type, plan_slug, amount, extra_minutes, period_start, period_end, payment_method, notes } = request.body as {
894
+ tenant_id: string;
895
+ type?: string;
896
+ plan_slug: string;
897
+ amount: number;
898
+ extra_minutes?: number;
899
+ period_start: string;
900
+ period_end: string;
901
+ payment_method?: string;
902
+ notes?: string;
903
+ };
904
+
905
+ if (!tenant_id || !plan_slug || amount == null || !period_start || !period_end) {
906
+ return reply.status(400).send({ error: 'tenant_id, plan_slug, amount, period_start, period_end are required' });
907
+ }
908
+
909
+ const paymentType = type === 'top_up' ? 'top_up' : 'subscription';
910
+ if (paymentType === 'top_up' && (!extra_minutes || extra_minutes <= 0)) {
911
+ return reply.status(400).send({ error: 'Ek dakika (extra_minutes) top-up icin zorunludur' });
912
+ }
913
+
914
+ const tenant = await Tenant.findById(tenant_id);
915
+ if (!tenant) {
916
+ return reply.status(404).send({ error: 'Tenant not found' });
917
+ }
918
+
919
+ // Lookup plan name from slug
920
+ const plan = await Plan.findOne({ slug: plan_slug }).lean();
921
+ const plan_name = paymentType === 'top_up'
922
+ ? `Ek Dakika (${extra_minutes} dk)`
923
+ : (plan?.name || plan_slug);
924
+
925
+ const user = request.user as { id: string };
926
+ const payment = await Payment.create({
927
+ tenant_id,
928
+ type: paymentType,
929
+ plan_slug,
930
+ plan_name,
931
+ amount,
932
+ extra_minutes: paymentType === 'top_up' ? (extra_minutes || 0) : 0,
933
+ period_start: new Date(period_start),
934
+ period_end: new Date(period_end),
935
+ payment_method: payment_method || 'cash',
936
+ notes: notes || '',
937
+ recorded_by: user.id
938
+ });
939
+
940
+ // Snapshot previous billing period (subscription only, if it's expired)
941
+ if (paymentType === 'subscription') {
942
+ const now = new Date();
943
+ const prevPayment = await Payment.findOne({
944
+ tenant_id,
945
+ type: 'subscription',
946
+ _id: { $ne: payment._id },
947
+ period_end: { $lt: now },
948
+ }).sort({ period_end: -1 }).lean();
949
+
950
+ if (prevPayment) {
951
+ snapshotPeriod(tenant_id, prevPayment.period_start, prevPayment.period_end, prevPayment._id.toString())
952
+ .catch(err => logger.error('Failed to snapshot billing period', { error: (err as Error).message }));
953
+ }
954
+ }
955
+
956
+ return reply.status(201).send({ payment });
957
+ });
958
+
959
+ // ─── ElevenLabs API (read-only proxies) ───────────────────
960
+
961
+ // GET /admin/elevenlabs/agents — list ElevenLabs agents
962
+ fastify.get<{
963
+ Querystring: { page_size?: string; search?: string; cursor?: string };
964
+ }>('/elevenlabs/agents', async (request) => {
965
+ const { page_size, search, cursor } = request.query;
966
+ return elevenlabsService.listAgents({
967
+ pageSize: page_size ? parseInt(page_size, 10) : undefined,
968
+ search: search || undefined,
969
+ cursor: cursor || undefined,
970
+ });
971
+ });
972
+
973
+ // GET /admin/elevenlabs/agents/:agentId — get single ElevenLabs agent
974
+ fastify.get<{
975
+ Params: { agentId: string };
976
+ }>('/elevenlabs/agents/:agentId', async (request) => {
977
+ const { agentId } = request.params;
978
+ return elevenlabsService.getAgent(agentId);
979
+ });
980
+
981
+ // GET /admin/elevenlabs/conversations — list conversations
982
+ fastify.get<{
983
+ Querystring: {
984
+ agent_id?: string;
985
+ page_size?: string;
986
+ cursor?: string;
987
+ call_successful?: string;
988
+ search?: string;
989
+ };
990
+ }>('/elevenlabs/conversations', async (request) => {
991
+ const { agent_id, page_size, cursor, call_successful, search } = request.query;
992
+ return elevenlabsService.listConversations({
993
+ agentId: agent_id || undefined,
994
+ pageSize: page_size ? parseInt(page_size, 10) : undefined,
995
+ cursor: cursor || undefined,
996
+ callSuccessful: call_successful as 'success' | 'failure' | 'unknown' | undefined,
997
+ search: search || undefined,
998
+ });
999
+ });
1000
+
1001
+ // GET /admin/elevenlabs/conversations/:conversationId — get conversation detail
1002
+ fastify.get<{
1003
+ Params: { conversationId: string };
1004
+ }>('/elevenlabs/conversations/:conversationId', async (request) => {
1005
+ const { conversationId } = request.params;
1006
+ return elevenlabsService.getConversation(conversationId);
1007
+ });
1008
+
1009
+ // GET /admin/elevenlabs/conversations/review — enriched list with evaluation status
1010
+ fastify.get<{
1011
+ Querystring: {
1012
+ agent_id?: string;
1013
+ page_size?: string;
1014
+ cursor?: string;
1015
+ call_successful?: string;
1016
+ };
1017
+ }>('/elevenlabs/conversations/review', async (request) => {
1018
+ const { agent_id, page_size, cursor, call_successful } = request.query;
1019
+ const currentUser = request.user as { id: string };
1020
+
1021
+ // Get all active claims by OTHER users (exclude our own claims)
1022
+ const otherClaims = await ConversationClaim.find({
1023
+ user_id: { $ne: currentUser.id },
1024
+ expires_at: { $gt: new Date() },
1025
+ }).lean();
1026
+ const claimedByOthers = new Set(otherClaims.map((c) => c.conversation_id));
1027
+
1028
+ const targetSize = page_size ? parseInt(page_size, 10) : 10;
1029
+ const results: any[] = [];
1030
+ let currentCursor = cursor || undefined;
1031
+ let hasMore = true;
1032
+ let nextCursor: string | undefined;
1033
+
1034
+ // Keep fetching pages until we have enough non-text conversations or run out
1035
+ while (results.length < targetSize && hasMore) {
1036
+ const listResult = await elevenlabsService.listConversations({
1037
+ agentId: agent_id || undefined,
1038
+ pageSize: 25, // fetch in chunks of 25
1039
+ cursor: currentCursor,
1040
+ callSuccessful: call_successful as 'success' | 'failure' | 'unknown' | undefined,
1041
+ });
1042
+
1043
+ hasMore = listResult.hasMore || false;
1044
+ nextCursor = listResult.nextCursor;
1045
+ currentCursor = listResult.nextCursor;
1046
+
1047
+ if (listResult.conversations.length === 0) break;
1048
+
1049
+ // Quick filter: skip text agents before expensive detail fetch
1050
+ const nonText = listResult.conversations.filter(
1051
+ (c) => !(c.agentName || '').toLowerCase().includes('text')
1052
+ );
1053
+
1054
+ if (nonText.length === 0) continue;
1055
+
1056
+ const conversationIds = nonText.map((c) => c.conversationId);
1057
+
1058
+ // Skip already-evaluated and claimed-by-others conversations
1059
+ const evaluations = await ConversationEvaluation.find({
1060
+ conversation_id: { $in: conversationIds },
1061
+ }).lean();
1062
+ const evaluatedIds = new Set(evaluations.map((e) => e.conversation_id));
1063
+ const unevaluated = nonText.filter((c) =>
1064
+ !evaluatedIds.has(c.conversationId) &&
1065
+ !claimedByOthers.has(c.conversationId)
1066
+ );
1067
+
1068
+ if (unevaluated.length === 0) continue;
1069
+
1070
+ const enriched = await Promise.all(
1071
+ unevaluated.map(async (c) => {
1072
+ try {
1073
+ const detail = await elevenlabsService.getConversation(c.conversationId);
1074
+ const phoneCall = detail.metadata?.phoneCall;
1075
+ return {
1076
+ conversationId: c.conversationId,
1077
+ agentId: c.agentId,
1078
+ agentName: c.agentName,
1079
+ status: c.status,
1080
+ callSuccessful: c.callSuccessful,
1081
+ callDurationSecs: c.callDurationSecs,
1082
+ messageCount: c.messageCount,
1083
+ direction: c.direction,
1084
+ startTimeUnixSecs: c.startTimeUnixSecs,
1085
+ transcriptSummary: c.transcriptSummary,
1086
+ externalNumber: phoneCall ? (phoneCall as any).externalNumber : null,
1087
+ agentNumber: phoneCall ? (phoneCall as any).agentNumber : null,
1088
+ terminationReason: detail.metadata?.terminationReason || null,
1089
+ cost: detail.metadata?.cost ?? null,
1090
+ hasAudio: detail.hasAudio ?? false,
1091
+ hasEvaluation: false,
1092
+ evaluation: null,
1093
+ };
1094
+ } catch {
1095
+ return {
1096
+ conversationId: c.conversationId,
1097
+ agentId: c.agentId,
1098
+ agentName: c.agentName,
1099
+ status: c.status,
1100
+ callSuccessful: c.callSuccessful,
1101
+ callDurationSecs: c.callDurationSecs,
1102
+ messageCount: c.messageCount,
1103
+ direction: c.direction,
1104
+ startTimeUnixSecs: c.startTimeUnixSecs,
1105
+ transcriptSummary: c.transcriptSummary,
1106
+ externalNumber: null,
1107
+ agentNumber: null,
1108
+ terminationReason: null,
1109
+ cost: null,
1110
+ hasAudio: false,
1111
+ hasEvaluation: false,
1112
+ evaluation: null,
1113
+ };
1114
+ }
1115
+ })
1116
+ );
1117
+
1118
+ results.push(...enriched);
1119
+ }
1120
+
1121
+ return {
1122
+ conversations: results.slice(0, targetSize),
1123
+ hasMore: hasMore || results.length > targetSize,
1124
+ nextCursor,
1125
+ };
1126
+ });
1127
+
1128
+ // GET /admin/elevenlabs/conversations/:conversationId/audio — stream audio
1129
+ fastify.get<{
1130
+ Params: { conversationId: string };
1131
+ }>('/elevenlabs/conversations/:conversationId/audio', async (request, reply) => {
1132
+ const { conversationId } = request.params;
1133
+ try {
1134
+ const audioResult = await elevenlabsService.getConversationAudio(conversationId);
1135
+
1136
+ reply
1137
+ .type('audio/mpeg')
1138
+ .header('Cache-Control', 'public, max-age=86400')
1139
+ .header('Accept-Ranges', 'bytes');
1140
+
1141
+ // Handle different return types from the SDK
1142
+ if (audioResult instanceof ReadableStream) {
1143
+ // Web ReadableStream → Node Readable
1144
+ const nodeStream = Readable.fromWeb(audioResult as any);
1145
+ return reply.send(nodeStream);
1146
+ } else if (Buffer.isBuffer(audioResult)) {
1147
+ return reply.send(audioResult);
1148
+ } else if (audioResult && typeof (audioResult as any).pipe === 'function') {
1149
+ // Already a Node.js Readable stream
1150
+ return reply.send(audioResult);
1151
+ } else if (audioResult && typeof (audioResult as any)[Symbol.asyncIterator] === 'function') {
1152
+ // Async iterable
1153
+ const chunks: Buffer[] = [];
1154
+ for await (const chunk of audioResult as any) {
1155
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1156
+ }
1157
+ return reply.send(Buffer.concat(chunks));
1158
+ } else {
1159
+ // Fallback: try to send as-is
1160
+ return reply.send(audioResult as any);
1161
+ }
1162
+ } catch (error) {
1163
+ logger.error('Failed to stream conversation audio', { conversationId, error: (error as Error).message });
1164
+ return reply.status(500).send({ error: 'Audio yuklenemedi' });
1165
+ }
1166
+ });
1167
+
1168
+ // GET /admin/elevenlabs/evaluations — list all evaluations with pagination
1169
+ fastify.get<{
1170
+ Querystring: {
1171
+ page?: string;
1172
+ limit?: string;
1173
+ evaluator_id?: string;
1174
+ skipped?: string;
1175
+ min_score?: string;
1176
+ max_score?: string;
1177
+ };
1178
+ }>('/elevenlabs/evaluations', async (request) => {
1179
+ const { page = '1', limit = '20', evaluator_id, skipped, min_score, max_score } = request.query;
1180
+ const pageNum = Math.max(1, parseInt(page, 10));
1181
+ const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10)));
1182
+ const skip = (pageNum - 1) * limitNum;
1183
+
1184
+ const filter: Record<string, unknown> = {};
1185
+ if (evaluator_id) filter.evaluator_id = evaluator_id;
1186
+ if (skipped === 'true') filter.skipped = true;
1187
+ if (skipped === 'false') filter.skipped = { $ne: true };
1188
+ if (min_score) filter.overall_score = { ...((filter.overall_score as object) || {}), $gte: parseFloat(min_score) };
1189
+ if (max_score) filter.overall_score = { ...((filter.overall_score as object) || {}), $lte: parseFloat(max_score) };
1190
+
1191
+ const [rawEvaluations, total] = await Promise.all([
1192
+ ConversationEvaluation.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limitNum).lean(),
1193
+ ConversationEvaluation.countDocuments(filter),
1194
+ ]);
1195
+
1196
+ // Enrich evaluations with conversation details from ElevenLabs
1197
+ const evaluations = await Promise.all(
1198
+ rawEvaluations.map(async (ev) => {
1199
+ try {
1200
+ const detail = await elevenlabsService.getConversation(ev.conversation_id);
1201
+ const phoneCall = detail.metadata?.phoneCall;
1202
+ return {
1203
+ ...ev,
1204
+ agentName: detail.agentName || null,
1205
+ agentId: detail.agentId || null,
1206
+ callDurationSecs: detail.metadata?.callDurationSecs || 0,
1207
+ startTimeUnixSecs: detail.metadata?.startTimeUnixSecs || 0,
1208
+ externalNumber: phoneCall ? (phoneCall as any).externalNumber : null,
1209
+ cost: detail.metadata?.cost ?? null,
1210
+ callSuccessful: detail.analysis?.callSuccessful || null,
1211
+ transcriptSummary: detail.analysis?.transcriptSummary || null,
1212
+ };
1213
+ } catch {
1214
+ return { ...ev, agentName: null, agentId: null, callDurationSecs: 0, startTimeUnixSecs: 0, externalNumber: null, cost: null, callSuccessful: null, transcriptSummary: null };
1215
+ }
1216
+ })
1217
+ );
1218
+
1219
+ // Aggregate stats
1220
+ const stats = await ConversationEvaluation.aggregate([
1221
+ { $match: { skipped: { $ne: true } } },
1222
+ {
1223
+ $group: {
1224
+ _id: null,
1225
+ total: { $sum: 1 },
1226
+ avg_score: { $avg: '$overall_score' },
1227
+ avg_patient_audio: { $avg: '$patient_audio_quality' },
1228
+ avg_ai_comprehension: { $avg: '$ai_comprehension' },
1229
+ avg_ai_speech: { $avg: '$ai_speech_quality' },
1230
+ avg_ai_content: { $avg: '$ai_content_quality' },
1231
+ avg_no_unnecessary: { $avg: '$no_unnecessary_content' },
1232
+ avg_patient_goal: { $avg: '$patient_reached_goal' },
1233
+ },
1234
+ },
1235
+ ]);
1236
+
1237
+ const skippedCount = await ConversationEvaluation.countDocuments({ skipped: true });
1238
+
1239
+ // Per-evaluator stats
1240
+ const evaluatorStats = await ConversationEvaluation.aggregate([
1241
+ { $match: { skipped: { $ne: true } } },
1242
+ {
1243
+ $group: {
1244
+ _id: '$evaluator_id',
1245
+ name: { $first: '$evaluator_name' },
1246
+ count: { $sum: 1 },
1247
+ avg_score: { $avg: '$overall_score' },
1248
+ },
1249
+ },
1250
+ { $sort: { count: -1 } },
1251
+ ]);
1252
+
1253
+ return {
1254
+ evaluations,
1255
+ total,
1256
+ page: pageNum,
1257
+ totalPages: Math.ceil(total / limitNum),
1258
+ stats: stats[0] || null,
1259
+ skippedCount,
1260
+ evaluatorStats,
1261
+ };
1262
+ });
1263
+
1264
+ // GET /admin/elevenlabs/conversations/:conversationId/evaluation
1265
+ fastify.get<{
1266
+ Params: { conversationId: string };
1267
+ }>('/elevenlabs/conversations/:conversationId/evaluation', async (request, reply) => {
1268
+ const { conversationId } = request.params;
1269
+ const evaluation = await ConversationEvaluation.findOne({ conversation_id: conversationId }).lean();
1270
+ if (!evaluation) {
1271
+ return reply.status(404).send({ error: 'Evaluation not found' });
1272
+ }
1273
+ return evaluation;
1274
+ });
1275
+
1276
+ // PUT /admin/elevenlabs/conversations/:conversationId/evaluation — upsert
1277
+ fastify.put<{
1278
+ Params: { conversationId: string };
1279
+ Body: {
1280
+ patient_audio_quality: number;
1281
+ ai_comprehension: number;
1282
+ ai_speech_quality: number;
1283
+ ai_content_quality: number;
1284
+ no_unnecessary_content: number;
1285
+ patient_reached_goal: number;
1286
+ notes?: string;
1287
+ };
1288
+ }>('/elevenlabs/conversations/:conversationId/evaluation', async (request) => {
1289
+ const { conversationId } = request.params;
1290
+ const { patient_audio_quality, ai_comprehension, ai_speech_quality, ai_content_quality, no_unnecessary_content, patient_reached_goal, notes } = request.body;
1291
+ const jwtUser = request.user as { id: string; role: string };
1292
+ const dbUser = await User.findById(jwtUser.id).select('name').lean();
1293
+ const evaluatorName = dbUser?.name || 'Superadmin';
1294
+
1295
+ const criteria = [patient_audio_quality, ai_comprehension, ai_speech_quality, ai_content_quality, no_unnecessary_content, patient_reached_goal].filter((v) => v > 0);
1296
+ const overall_score = criteria.length > 0 ? Math.round((criteria.reduce((a, b) => a + b, 0) / criteria.length) * 10) / 10 : 0;
1297
+
1298
+ const evaluation = await ConversationEvaluation.findOneAndUpdate(
1299
+ { conversation_id: conversationId },
1300
+ {
1301
+ $set: {
1302
+ evaluator_id: jwtUser.id,
1303
+ evaluator_name: evaluatorName,
1304
+ skipped: false,
1305
+ patient_audio_quality: patient_audio_quality || null,
1306
+ ai_comprehension: ai_comprehension || null,
1307
+ ai_speech_quality: ai_speech_quality || null,
1308
+ ai_content_quality: ai_content_quality || null,
1309
+ no_unnecessary_content: no_unnecessary_content || null,
1310
+ patient_reached_goal: patient_reached_goal || null,
1311
+ overall_score,
1312
+ notes: notes || '',
1313
+ },
1314
+ },
1315
+ { upsert: true, new: true }
1316
+ );
1317
+
1318
+ return evaluation;
1319
+ });
1320
+
1321
+ // DELETE /admin/elevenlabs/conversations/:conversationId/evaluation
1322
+ fastify.delete<{
1323
+ Params: { conversationId: string };
1324
+ }>('/elevenlabs/conversations/:conversationId/evaluation', async (request, reply) => {
1325
+ const { conversationId } = request.params;
1326
+ const result = await ConversationEvaluation.deleteOne({ conversation_id: conversationId });
1327
+ if (result.deletedCount === 0) {
1328
+ return reply.status(404).send({ error: 'Evaluation not found' });
1329
+ }
1330
+ return { deleted: true };
1331
+ });
1332
+
1333
+ // POST /admin/elevenlabs/conversations/:conversationId/claim — claim for review (5 min TTL)
1334
+ fastify.post<{
1335
+ Params: { conversationId: string };
1336
+ }>('/elevenlabs/conversations/:conversationId/claim', async (request) => {
1337
+ const { conversationId } = request.params;
1338
+ const jwtUser = request.user as { id: string };
1339
+ const dbUser = await User.findById(jwtUser.id).select('name').lean();
1340
+
1341
+ await ConversationClaim.findOneAndUpdate(
1342
+ { conversation_id: conversationId },
1343
+ {
1344
+ $set: {
1345
+ user_id: jwtUser.id,
1346
+ user_name: dbUser?.name || 'Superadmin',
1347
+ expires_at: new Date(Date.now() + 5 * 60 * 1000), // 5 min TTL
1348
+ },
1349
+ },
1350
+ { upsert: true }
1351
+ );
1352
+
1353
+ return { claimed: true };
1354
+ });
1355
+
1356
+ // POST /admin/elevenlabs/conversations/:conversationId/skip — mark as not evaluable
1357
+ fastify.post<{
1358
+ Params: { conversationId: string };
1359
+ Body: { notes?: string };
1360
+ }>('/elevenlabs/conversations/:conversationId/skip', async (request) => {
1361
+ const { conversationId } = request.params;
1362
+ const jwtUser = request.user as { id: string };
1363
+ const dbUser = await User.findById(jwtUser.id).select('name').lean();
1364
+ const evaluatorName = dbUser?.name || 'Superadmin';
1365
+
1366
+ const evaluation = await ConversationEvaluation.findOneAndUpdate(
1367
+ { conversation_id: conversationId },
1368
+ {
1369
+ $set: {
1370
+ evaluator_id: jwtUser.id,
1371
+ evaluator_name: evaluatorName,
1372
+ skipped: true,
1373
+ notes: request.body?.notes || '',
1374
+ overall_score: 0,
1375
+ },
1376
+ },
1377
+ { upsert: true, new: true }
1378
+ );
1379
+
1380
+ return evaluation;
1381
+ });
1382
+
1383
+ // GET /admin/elevenlabs/inbound-calls — list SIP trunk inbound calls with phone metadata
1384
+ fastify.get<{
1385
+ Querystring: {
1386
+ agent_id?: string;
1387
+ page_size?: string;
1388
+ cursor?: string;
1389
+ call_successful?: string;
1390
+ search?: string;
1391
+ };
1392
+ }>('/elevenlabs/inbound-calls', async (request) => {
1393
+ const { agent_id, page_size, cursor, call_successful, search } = request.query;
1394
+ const listResult = await elevenlabsService.listConversations({
1395
+ agentId: agent_id || undefined,
1396
+ pageSize: page_size ? parseInt(page_size, 10) : undefined,
1397
+ cursor: cursor || undefined,
1398
+ callSuccessful: call_successful as 'success' | 'failure' | 'unknown' | undefined,
1399
+ search: search || undefined,
1400
+ conversationInitiationSource: 'sip_trunk',
1401
+ });
1402
+
1403
+ // Enrich each conversation with phone call metadata from detail endpoint
1404
+ const enriched = await Promise.all(
1405
+ listResult.conversations.map(async (c) => {
1406
+ try {
1407
+ const detail = await elevenlabsService.getConversation(c.conversationId);
1408
+ const phoneCall = detail.metadata?.phoneCall;
1409
+ return {
1410
+ conversationId: c.conversationId,
1411
+ agentId: c.agentId,
1412
+ agentName: c.agentName,
1413
+ status: c.status,
1414
+ callSuccessful: c.callSuccessful,
1415
+ callDurationSecs: c.callDurationSecs,
1416
+ messageCount: c.messageCount,
1417
+ direction: c.direction,
1418
+ startTimeUnixSecs: c.startTimeUnixSecs,
1419
+ rating: c.rating,
1420
+ transcriptSummary: c.transcriptSummary,
1421
+ phoneCallType: phoneCall ? (phoneCall as any).type : null,
1422
+ callSid: phoneCall ? (phoneCall as any).callSid : null,
1423
+ agentNumber: phoneCall ? (phoneCall as any).agentNumber : null,
1424
+ externalNumber: phoneCall ? (phoneCall as any).externalNumber : null,
1425
+ phoneNumberId: phoneCall ? (phoneCall as any).phoneNumberId : null,
1426
+ terminationReason: detail.metadata?.terminationReason || null,
1427
+ cost: detail.metadata?.cost ?? null,
1428
+ };
1429
+ } catch {
1430
+ return {
1431
+ conversationId: c.conversationId,
1432
+ agentId: c.agentId,
1433
+ agentName: c.agentName,
1434
+ status: c.status,
1435
+ callSuccessful: c.callSuccessful,
1436
+ callDurationSecs: c.callDurationSecs,
1437
+ messageCount: c.messageCount,
1438
+ direction: c.direction,
1439
+ startTimeUnixSecs: c.startTimeUnixSecs,
1440
+ rating: c.rating,
1441
+ transcriptSummary: c.transcriptSummary,
1442
+ phoneCallType: null,
1443
+ callSid: null,
1444
+ agentNumber: null,
1445
+ externalNumber: null,
1446
+ phoneNumberId: null,
1447
+ terminationReason: null,
1448
+ cost: null,
1449
+ };
1450
+ }
1451
+ })
1452
+ );
1453
+
1454
+ return {
1455
+ conversations: enriched,
1456
+ hasMore: listResult.hasMore,
1457
+ nextCursor: listResult.nextCursor,
1458
+ };
1459
+ });
1460
+
1461
+ // GET /admin/elevenlabs/batch-calls — list batch calls
1462
+ fastify.get<{
1463
+ Querystring: { limit?: string; last_doc?: string };
1464
+ }>('/elevenlabs/batch-calls', async (request) => {
1465
+ const { limit, last_doc } = request.query;
1466
+ return elevenlabsService.listBatchCalls({
1467
+ limit: limit ? parseInt(limit, 10) : undefined,
1468
+ lastDoc: last_doc || undefined,
1469
+ });
1470
+ });
1471
+
1472
+ // GET /admin/elevenlabs/batch-calls/:batchId — get batch call detail
1473
+ fastify.get<{
1474
+ Params: { batchId: string };
1475
+ }>('/elevenlabs/batch-calls/:batchId', async (request) => {
1476
+ const { batchId } = request.params;
1477
+ return elevenlabsService.getBatchCall(batchId);
1478
+ });
1479
+
1480
+ // POST /admin/elevenlabs/batch-calls — trigger a new batch call
1481
+ fastify.post('/elevenlabs/batch-calls', async (request, reply) => {
1482
+ const { name, agent_id, phone_numbers } = request.body as {
1483
+ name: string;
1484
+ agent_id: string;
1485
+ phone_numbers: string[];
1486
+ };
1487
+
1488
+ if (!name || !agent_id || !phone_numbers?.length) {
1489
+ return reply.status(400).send({ error: 'name, agent_id, and phone_numbers are required' });
1490
+ }
1491
+
1492
+ const recipients = phone_numbers
1493
+ .map((n) => n.trim())
1494
+ .filter(Boolean)
1495
+ .map((n) => ({ phoneNumber: n.startsWith('+') ? n : `+${n}` }));
1496
+
1497
+ if (recipients.length === 0) {
1498
+ return reply.status(400).send({ error: 'At least one valid phone number is required' });
1499
+ }
1500
+
1501
+ const result = await elevenlabsService.submitBatchCall({
1502
+ callName: name,
1503
+ agentId: agent_id,
1504
+ recipients,
1505
+ });
1506
+
1507
+ return reply.status(201).send(result);
1508
+ });
1509
+
1510
+ // ─── ElevenLabs Webhook Management ──────────────────────────
1511
+
1512
+ // GET /admin/elevenlabs/webhooks — list workspace webhooks
1513
+ fastify.get('/elevenlabs/webhooks', async () => {
1514
+ return elevenlabsService.listWebhooks({ includeUsages: true });
1515
+ });
1516
+
1517
+ // POST /admin/elevenlabs/webhooks — create workspace webhook
1518
+ fastify.post('/elevenlabs/webhooks', async (request, reply) => {
1519
+ const { name, webhookUrl } = request.body as { name: string; webhookUrl: string };
1520
+ if (!name || !webhookUrl) {
1521
+ return reply.status(400).send({ error: 'name and webhookUrl are required' });
1522
+ }
1523
+ return elevenlabsService.createWebhook({
1524
+ settings: { authType: 'hmac', name, webhookUrl },
1525
+ });
1526
+ });
1527
+
1528
+ // PATCH /admin/elevenlabs/webhooks/:webhookId — update webhook
1529
+ fastify.patch<{
1530
+ Params: { webhookId: string };
1531
+ }>('/elevenlabs/webhooks/:webhookId', async (request) => {
1532
+ const { webhookId } = request.params;
1533
+ const { isDisabled, name } = request.body as { isDisabled?: boolean; name?: string };
1534
+ return elevenlabsService.updateWebhook(webhookId, {
1535
+ isDisabled: isDisabled ?? false,
1536
+ name: name ?? '',
1537
+ });
1538
+ });
1539
+
1540
+ // DELETE /admin/elevenlabs/webhooks/:webhookId — delete webhook
1541
+ fastify.delete<{
1542
+ Params: { webhookId: string };
1543
+ }>('/elevenlabs/webhooks/:webhookId', async (request) => {
1544
+ const { webhookId } = request.params;
1545
+ return elevenlabsService.deleteWebhook(webhookId);
1546
+ });
1547
+
1548
+ // PATCH /admin/elevenlabs/agents/:agentId/webhook — assign post-call webhook to agent
1549
+ fastify.patch<{
1550
+ Params: { agentId: string };
1551
+ }>('/elevenlabs/agents/:agentId/webhook', async (request, reply) => {
1552
+ const { agentId } = request.params;
1553
+ const { postCallWebhookId, events } = request.body as {
1554
+ postCallWebhookId: string;
1555
+ events?: ('transcript' | 'audio' | 'call_initiation_failure')[];
1556
+ };
1557
+ if (!postCallWebhookId) {
1558
+ return reply.status(400).send({ error: 'postCallWebhookId is required' });
1559
+ }
1560
+ return elevenlabsService.updateAgent(agentId, {
1561
+ platformSettings: {
1562
+ workspaceOverrides: {
1563
+ webhooks: {
1564
+ postCallWebhookId,
1565
+ events: events || ['transcript'],
1566
+ },
1567
+ },
1568
+ },
1569
+ });
1570
+ });
1571
+
1572
+ // DELETE /admin/elevenlabs/agents/:agentId/webhook — remove post-call webhook from agent
1573
+ fastify.delete<{
1574
+ Params: { agentId: string };
1575
+ }>('/elevenlabs/agents/:agentId/webhook', async (request) => {
1576
+ const { agentId } = request.params;
1577
+ return elevenlabsService.updateAgent(agentId, {
1578
+ platformSettings: {
1579
+ workspaceOverrides: {
1580
+ webhooks: {
1581
+ postCallWebhookId: '',
1582
+ events: [],
1583
+ },
1584
+ },
1585
+ },
1586
+ });
1587
+ });
1588
+
1589
+ // ─── WhatsApp Bridge Provider ────────────────────────────────
1590
+
1591
+ // GET /admin/whatsapp-providers — List available bridge providers
1592
+ fastify.get('/whatsapp-providers', async () => {
1593
+ const unipileService = (await import('../../services/unipile.service')).default;
1594
+ return { providers: unipileService.getAvailableProviders() };
1595
+ });
1596
+
1597
+ // PUT /admin/tenants/:id/whatsapp-bridge — Set tenant's bridge provider
1598
+ fastify.put<{ Params: { id: string } }>('/tenants/:id/whatsapp-bridge', async (request, reply) => {
1599
+ const { id } = request.params;
1600
+ const { provider } = request.body as { provider: string };
1601
+
1602
+ if (!['unipile', 'baileys'].includes(provider)) {
1603
+ return reply.status(400).send({ error: 'Invalid provider. Must be "unipile" or "baileys"' });
1604
+ }
1605
+
1606
+ const unipileService = (await import('../../services/unipile.service')).default;
1607
+ if (!unipileService.isProviderReady(provider)) {
1608
+ return reply.status(400).send({ error: `Provider "${provider}" is not configured on this server` });
1609
+ }
1610
+
1611
+ const tenant = await Tenant.findById(id);
1612
+ if (!tenant) {
1613
+ return reply.status(404).send({ error: 'Tenant not found' });
1614
+ }
1615
+
1616
+ // Refuse if tenant has active connection — must disconnect first
1617
+ if (tenant.settings?.whatsapp_agent?.unipile_account_id) {
1618
+ return reply.status(400).send({
1619
+ error: 'Tenant has an active WhatsApp connection. Disconnect first before switching providers.',
1620
+ });
1621
+ }
1622
+
1623
+ await Tenant.findByIdAndUpdate(id, {
1624
+ $set: { 'settings.whatsapp_agent.whatsapp_bridge_provider': provider }
1625
+ });
1626
+
1627
+ return { success: true, provider };
1628
+ });
1629
+
1630
+ // ─── Leads (cross-tenant) ──────────────────────────────────
1631
+
1632
+ // GET /admin/leads — all leads across tenants
1633
+ fastify.get<{
1634
+ Querystring: {
1635
+ page?: string;
1636
+ limit?: string;
1637
+ lead_status?: string;
1638
+ channel?: string;
1639
+ category?: string;
1640
+ tenant_id?: string;
1641
+ date_from?: string;
1642
+ date_to?: string;
1643
+ };
1644
+ }>('/leads', async (request) => {
1645
+ const page = parseInt(request.query.page || '1', 10);
1646
+ const limit = Math.min(parseInt(request.query.limit || '30', 10), 100);
1647
+ const skip = (page - 1) * limit;
1648
+
1649
+ const filter: Record<string, unknown> = {};
1650
+
1651
+ if (request.query.tenant_id) filter.tenant_id = request.query.tenant_id;
1652
+ if (request.query.lead_status) {
1653
+ filter.lead_status = request.query.lead_status;
1654
+ } else {
1655
+ filter.lead_status = { $ne: 'expired' };
1656
+ }
1657
+ if (request.query.channel) filter.channel = request.query.channel;
1658
+ if (request.query.category) filter.categories = request.query.category;
1659
+ if (request.query.date_from || request.query.date_to) {
1660
+ const dateFilter: Record<string, Date> = {};
1661
+ if (request.query.date_from) dateFilter.$gte = new Date(request.query.date_from);
1662
+ if (request.query.date_to) {
1663
+ const to = new Date(request.query.date_to);
1664
+ to.setHours(23, 59, 59, 999);
1665
+ dateFilter.$lte = to;
1666
+ }
1667
+ filter.createdAt = dateFilter;
1668
+ }
1669
+
1670
+ const [leads, total] = await Promise.all([
1671
+ Lead.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(),
1672
+ Lead.countDocuments(filter)
1673
+ ]);
1674
+
1675
+ // Enrich with tenant names
1676
+ const tenantIds = [...new Set(leads.map(l => String(l.tenant_id)))];
1677
+ const tenants = await Tenant.find({ _id: { $in: tenantIds } }).select('name').lean();
1678
+ const tenantMap = new Map(tenants.map(t => [String(t._id), t.name]));
1679
+
1680
+ const enriched = leads.map(l => ({
1681
+ ...l,
1682
+ tenant_name: tenantMap.get(String(l.tenant_id)) || '-',
1683
+ }));
1684
+
1685
+ return {
1686
+ leads: enriched,
1687
+ total,
1688
+ page,
1689
+ totalPages: Math.ceil(total / limit)
1690
+ };
1691
+ });
1692
+
1693
+ // GET /admin/leads/stats — cross-tenant lead stats
1694
+ fastify.get('/leads/stats', async () => {
1695
+ const [total, byStatus, byChannel] = await Promise.all([
1696
+ Lead.countDocuments({ lead_status: { $ne: 'expired' } }),
1697
+ Lead.aggregate([
1698
+ { $match: { lead_status: { $ne: 'expired' } } },
1699
+ { $group: { _id: '$lead_status', count: { $sum: 1 } } }
1700
+ ]),
1701
+ Lead.aggregate([
1702
+ { $match: { lead_status: { $ne: 'expired' } } },
1703
+ { $group: { _id: '$channel', count: { $sum: 1 } } }
1704
+ ])
1705
+ ]);
1706
+
1707
+ const statusMap: Record<string, number> = {};
1708
+ for (const s of byStatus) statusMap[s._id] = s.count;
1709
+ const channelMap: Record<string, number> = {};
1710
+ for (const c of byChannel) channelMap[c._id] = c.count;
1711
+
1712
+ return {
1713
+ stats: {
1714
+ total,
1715
+ new: statusMap['new'] || 0,
1716
+ contacted: statusMap['contacted'] || 0,
1717
+ appointment_scheduled: statusMap['appointment_scheduled'] || 0,
1718
+ completed: statusMap['completed'] || 0,
1719
+ lost: statusMap['lost'] || 0,
1720
+ phone: channelMap['phone'] || 0,
1721
+ whatsapp: channelMap['whatsapp'] || 0,
1722
+ }
1723
+ };
1724
+ });
1725
+
1726
+ // ─── WhatsApp (cross-tenant, read-only) ────────────────────
1727
+
1728
+ // GET /admin/whatsapp/stats — cross-tenant WhatsApp stats
1729
+ fastify.get('/whatsapp/stats', async () => {
1730
+ const [totalChats, closedChats, waitingSessions, activeSessions, totalMessages] = await Promise.all([
1731
+ WhatsAppChat.countDocuments({ deleted_at: null }),
1732
+ WhatsAppChat.countDocuments({ is_closed: true, deleted_at: null }),
1733
+ WhatsAppSession.countDocuments({ status: 'waiting' }),
1734
+ WhatsAppSession.countDocuments({ status: 'active' }),
1735
+ WhatsAppMessage.countDocuments(),
1736
+ ]);
1737
+
1738
+ const humanTakeoverCount = await WhatsAppSession.countDocuments({
1739
+ status: 'active',
1740
+ $or: [{ taken_over_by: { $ne: null } }, { taken_over_at: { $ne: null } }],
1741
+ });
1742
+
1743
+ return {
1744
+ stats: {
1745
+ totalChats,
1746
+ aiChats: activeSessions - humanTakeoverCount,
1747
+ humanChats: humanTakeoverCount + waitingSessions,
1748
+ closedChats,
1749
+ waitingChats: waitingSessions,
1750
+ totalMessages,
1751
+ },
1752
+ };
1753
+ });
1754
+
1755
+ // GET /admin/whatsapp/agents — list WhatsApp agents assigned to tenants (for test chat)
1756
+ fastify.get('/whatsapp/agents', async () => {
1757
+ const tenants = await Tenant.find({
1758
+ 'settings.whatsapp_agent.agent_id': { $ne: null },
1759
+ })
1760
+ .select('name settings.whatsapp_agent.agent_id settings.whatsapp_agent.connected_phone')
1761
+ .lean();
1762
+
1763
+ const agentIds = tenants
1764
+ .map(t => t.settings?.whatsapp_agent?.agent_id)
1765
+ .filter(Boolean);
1766
+
1767
+ const agents = await Agent.find({ _id: { $in: agentIds } })
1768
+ .select('elevenlabs_agent_id is_active')
1769
+ .lean();
1770
+
1771
+ const agentMap = new Map(agents.map(a => [String(a._id), a]));
1772
+
1773
+ const result = tenants
1774
+ .map(t => {
1775
+ const agentObjId = t.settings?.whatsapp_agent?.agent_id;
1776
+ const agent = agentObjId ? agentMap.get(String(agentObjId)) : null;
1777
+ if (!agent || !agent.elevenlabs_agent_id) return null;
1778
+ return {
1779
+ tenant_id: String(t._id),
1780
+ tenant_name: t.name,
1781
+ agent_id: String(agent._id),
1782
+ elevenlabs_agent_id: agent.elevenlabs_agent_id,
1783
+ is_active: agent.is_active,
1784
+ connected_phone: t.settings?.whatsapp_agent?.connected_phone || null,
1785
+ };
1786
+ })
1787
+ .filter(Boolean);
1788
+
1789
+ return { agents: result };
1790
+ });
1791
+
1792
+ // GET /admin/whatsapp/contact-profiles — cross-tenant contact profiles (for test chat)
1793
+ fastify.get<{
1794
+ Querystring: { search?: string; limit?: string };
1795
+ }>('/whatsapp/contact-profiles', async (request) => {
1796
+ const limit = Math.min(parseInt(request.query.limit || '50', 10), 100);
1797
+ const search = request.query.search?.trim();
1798
+
1799
+ const filter: Record<string, unknown> = {};
1800
+ if (search) {
1801
+ filter.$or = [
1802
+ { display_name: { $regex: search, $options: 'i' } },
1803
+ { phone: { $regex: search, $options: 'i' } },
1804
+ ];
1805
+ }
1806
+
1807
+ const profiles = await WhatsAppContactProfile.find(filter)
1808
+ .select('display_name phone ai_summary preferred_language tenant_id tags total_sessions')
1809
+ .sort({ last_seen_at: -1 })
1810
+ .limit(limit)
1811
+ .lean();
1812
+
1813
+ // Truncate ai_summary to 100 chars for listing
1814
+ const result = profiles.map(p => ({
1815
+ ...p,
1816
+ ai_summary: p.ai_summary ? p.ai_summary.substring(0, 100) + (p.ai_summary.length > 100 ? '...' : '') : '',
1817
+ }));
1818
+
1819
+ return { profiles: result };
1820
+ });
1821
+
1822
+ // GET /admin/whatsapp/chats — list all chats across tenants
1823
+ fastify.get<{
1824
+ Querystring: { page?: string; limit?: string; tenant_id?: string; search?: string };
1825
+ }>('/whatsapp/chats', async (request) => {
1826
+ const page = parseInt(request.query.page || '1', 10);
1827
+ const limit = Math.min(parseInt(request.query.limit || '20', 10), 100);
1828
+ const skip = (page - 1) * limit;
1829
+
1830
+ const filter: Record<string, unknown> = { deleted_at: null };
1831
+ if (request.query.tenant_id) {
1832
+ filter.tenant_id = new mongoose.Types.ObjectId(request.query.tenant_id);
1833
+ }
1834
+
1835
+ const pipeline: any[] = [
1836
+ { $match: filter },
1837
+ { $sort: { last_message_at: -1 as const } },
1838
+ {
1839
+ $lookup: {
1840
+ from: 'tenants',
1841
+ localField: 'tenant_id',
1842
+ foreignField: '_id',
1843
+ as: '_tenant',
1844
+ },
1845
+ },
1846
+ { $unwind: { path: '$_tenant', preserveNullAndEmptyArrays: true } },
1847
+ ];
1848
+
1849
+ // Search by contact name/phone or tenant name
1850
+ if (request.query.search) {
1851
+ const regex = { $regex: request.query.search, $options: 'i' };
1852
+ pipeline.push({
1853
+ $match: {
1854
+ $or: [
1855
+ { contact_name: regex },
1856
+ { contact_phone: regex },
1857
+ { '_tenant.name': regex },
1858
+ ],
1859
+ },
1860
+ });
1861
+ }
1862
+
1863
+ pipeline.push(
1864
+ {
1865
+ $project: {
1866
+ tenant_id: 1,
1867
+ tenant_name: { $ifNull: ['$_tenant.name', 'Bilinmiyor'] },
1868
+ unipile_chat_id: 1,
1869
+ contact_name: 1,
1870
+ contact_phone: 1,
1871
+ is_closed: 1,
1872
+ active_session_id: 1,
1873
+ last_message_at: 1,
1874
+ last_message_preview: 1,
1875
+ message_count: 1,
1876
+ createdAt: 1,
1877
+ },
1878
+ },
1879
+ {
1880
+ $facet: {
1881
+ data: [{ $skip: skip }, { $limit: limit }],
1882
+ count: [{ $count: 'total' }],
1883
+ },
1884
+ },
1885
+ );
1886
+
1887
+ const [result] = await WhatsAppChat.aggregate(pipeline);
1888
+ const chats = result?.data || [];
1889
+ const total = result?.count?.[0]?.total || 0;
1890
+
1891
+ return { chats, total, page, totalPages: Math.ceil(total / limit) };
1892
+ });
1893
+
1894
+ // GET /admin/whatsapp/chats/:chatId — single chat detail
1895
+ fastify.get<{ Params: { chatId: string } }>('/whatsapp/chats/:chatId', async (request, reply) => {
1896
+ const { chatId } = request.params;
1897
+ const chat = await WhatsAppChat.findById(chatId).lean();
1898
+ if (!chat) return reply.status(404).send({ error: 'Chat not found' });
1899
+
1900
+ const tenant = await Tenant.findById(chat.tenant_id).select('name settings.whatsapp_agent.grace_period_seconds').lean();
1901
+
1902
+ return {
1903
+ chat: {
1904
+ ...chat,
1905
+ tenant_name: tenant?.name || 'Bilinmiyor',
1906
+ grace_period_seconds: tenant?.settings?.whatsapp_agent?.grace_period_seconds ?? 180,
1907
+ },
1908
+ };
1909
+ });
1910
+
1911
+ // GET /admin/whatsapp/chats/:chatId/messages — messages for a chat
1912
+ fastify.get<{
1913
+ Params: { chatId: string };
1914
+ Querystring: { page?: string; limit?: string };
1915
+ }>('/whatsapp/chats/:chatId/messages', async (request, reply) => {
1916
+ const { chatId } = request.params;
1917
+ const page = parseInt(request.query.page || '1', 10);
1918
+ const limit = Math.min(parseInt(request.query.limit || '50', 10), 200);
1919
+ const skip = (page - 1) * limit;
1920
+
1921
+ const chat = await WhatsAppChat.findById(chatId);
1922
+ if (!chat) return reply.status(404).send({ error: 'Chat not found' });
1923
+
1924
+ const [messages, total] = await Promise.all([
1925
+ WhatsAppMessage.find({ chat_id: chatId })
1926
+ .sort({ createdAt: 1 })
1927
+ .skip(skip)
1928
+ .limit(limit)
1929
+ .lean(),
1930
+ WhatsAppMessage.countDocuments({ chat_id: chatId }),
1931
+ ]);
1932
+
1933
+ return { messages, total, page, totalPages: Math.ceil(total / limit) };
1934
+ });
1935
+
1936
+ // GET /admin/whatsapp/chats/:chatId/sessions — session history for a chat
1937
+ fastify.get<{
1938
+ Params: { chatId: string };
1939
+ Querystring: { page?: string; limit?: string };
1940
+ }>('/whatsapp/chats/:chatId/sessions', async (request, reply) => {
1941
+ const { chatId } = request.params;
1942
+ const page = parseInt(request.query.page || '1', 10);
1943
+ const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
1944
+ const skip = (page - 1) * limit;
1945
+
1946
+ const chat = await WhatsAppChat.findById(chatId);
1947
+ if (!chat) return reply.status(404).send({ error: 'Chat not found' });
1948
+
1949
+ const [sessions, total] = await Promise.all([
1950
+ WhatsAppSession.find({ chat_id: chatId })
1951
+ .populate('taken_over_by', 'name email')
1952
+ .sort({ started_at: -1 })
1953
+ .skip(skip)
1954
+ .limit(limit)
1955
+ .lean(),
1956
+ WhatsAppSession.countDocuments({ chat_id: chatId }),
1957
+ ]);
1958
+
1959
+ return { sessions, total, page, totalPages: Math.ceil(total / limit) };
1960
+ });
1961
+
1962
+ // DELETE /admin/whatsapp/chats/:chatId — HARD delete chat + messages + sessions
1963
+ fastify.delete<{ Params: { chatId: string } }>('/whatsapp/chats/:chatId', async (request, reply) => {
1964
+ const { chatId } = request.params;
1965
+ const chat = await WhatsAppChat.findById(chatId);
1966
+ if (!chat) return reply.status(404).send({ error: 'Chat not found' });
1967
+
1968
+ // 1. Close any active WS connection and cancel timers
1969
+ if (chat.unipile_chat_id) {
1970
+ whatsappAgentService.closeConnection(chat.unipile_chat_id);
1971
+ }
1972
+ if (chat.active_session_id) {
1973
+ whatsappAgentService.cancelSession(chat.active_session_id.toString());
1974
+ }
1975
+
1976
+ // 2. Hard-delete all related documents
1977
+ const [msgResult, sessResult] = await Promise.all([
1978
+ WhatsAppMessage.deleteMany({ chat_id: chatId }),
1979
+ WhatsAppSession.deleteMany({ chat_id: chatId }),
1980
+ ]);
1981
+
1982
+ // 3. Delete linked contact profile
1983
+ let profileDeleted = 0;
1984
+ if (chat.contact_profile_id) {
1985
+ const res = await WhatsAppContactProfile.deleteOne({ _id: chat.contact_profile_id });
1986
+ profileDeleted = res.deletedCount;
1987
+ }
1988
+
1989
+ // 4. Hard-delete the chat itself
1990
+ await WhatsAppChat.deleteOne({ _id: chatId });
1991
+
1992
+ return {
1993
+ deleted: {
1994
+ chat: 1,
1995
+ messages: msgResult.deletedCount,
1996
+ sessions: sessResult.deletedCount,
1997
+ contactProfile: profileDeleted,
1998
+ },
1999
+ };
2000
+ });
2001
+
2002
+ // PUT /admin/whatsapp/grace-period — set grace period for a tenant (no minimum, for debugging)
2003
+ fastify.put<{
2004
+ Body: { tenant_id: string; grace_period_seconds: number };
2005
+ }>('/whatsapp/grace-period', async (request, reply) => {
2006
+ const body = request.body as { tenant_id?: string; grace_period_seconds?: number } | null;
2007
+ const tenant_id = body?.tenant_id ? String(body.tenant_id) : null;
2008
+ const grace_period_seconds = body?.grace_period_seconds;
2009
+
2010
+ if (!tenant_id || grace_period_seconds === undefined || grace_period_seconds === null) {
2011
+ return reply.status(400).send({ error: 'tenant_id and grace_period_seconds required' });
2012
+ }
2013
+
2014
+ const clamped = Math.max(1, Math.min(1800, Math.floor(grace_period_seconds)));
2015
+
2016
+ const tenant = await Tenant.findByIdAndUpdate(
2017
+ new mongoose.Types.ObjectId(tenant_id),
2018
+ { $set: { 'settings.whatsapp_agent.grace_period_seconds': clamped } },
2019
+ { new: true },
2020
+ );
2021
+
2022
+ if (!tenant) return reply.status(404).send({ error: 'Tenant not found' });
2023
+
2024
+ return {
2025
+ tenant_id: String(tenant._id),
2026
+ grace_period_seconds: tenant.settings?.whatsapp_agent?.grace_period_seconds ?? clamped,
2027
+ };
2028
+ });
2029
+
2030
+ // POST /admin/whatsapp/chats/:chatId/force-resolve — force-end active session (debug)
2031
+ fastify.post<{ Params: { chatId: string } }>('/whatsapp/chats/:chatId/force-resolve', async (request, reply) => {
2032
+ const { chatId } = request.params;
2033
+ const chat = await WhatsAppChat.findById(chatId);
2034
+ if (!chat) return reply.status(404).send({ error: 'Chat not found' });
2035
+
2036
+ if (!chat.active_session_id) {
2037
+ return reply.status(400).send({ error: 'No active session' });
2038
+ }
2039
+
2040
+ const session = await WhatsAppSession.findById(chat.active_session_id);
2041
+ if (!session || session.status === 'resolved') {
2042
+ chat.active_session_id = null;
2043
+ await chat.save();
2044
+ return reply.status(400).send({ error: 'Session already resolved' });
2045
+ }
2046
+
2047
+ // Cancel timers (grace period + message buffer)
2048
+ whatsappAgentService.cancelSession(session._id.toString());
2049
+
2050
+ // Close ElevenLabs WebSocket connection
2051
+ whatsappAgentService.closeConnection(chat.unipile_chat_id);
2052
+
2053
+ // Resolve the session
2054
+ session.status = 'resolved';
2055
+ session.resolved_by = 'manual';
2056
+ session.resolved_at = new Date();
2057
+ await session.save();
2058
+
2059
+ // Clear active session from chat
2060
+ chat.active_session_id = null;
2061
+ await chat.save();
2062
+
2063
+ // Generate AI profile summary + labels from the session transcript
2064
+ whatsappAgentService.updateContactProfileSummary(session._id.toString()).catch(err => {
2065
+ fastify.log.error({ error: (err as Error).message }, 'Profile summary update failed after force-resolve');
2066
+ });
2067
+ whatsappAgentService.generateSessionLabels(session._id.toString()).catch(err => {
2068
+ fastify.log.error({ error: (err as Error).message }, 'Session labelling failed after force-resolve');
2069
+ });
2070
+
2071
+ return {
2072
+ resolved: {
2073
+ session_id: String(session._id),
2074
+ previous_status: session.status === 'resolved' ? 'resolved' : (session.taken_over_by ? 'active (human)' : session.status),
2075
+ },
2076
+ };
2077
+ });
2078
+
2079
+ // ─── Baileys Logs ─────────────────────────────────────────
2080
+
2081
+ /**
2082
+ * Build accountId→tenant lookup map.
2083
+ * Maps Baileys account UUIDs to { tenant_id, tenant_name }.
2084
+ */
2085
+ async function buildAccountTenantMap() {
2086
+ const tenants = await Tenant.find(
2087
+ { 'settings.whatsapp_agent.unipile_account_id': { $ne: null } },
2088
+ { name: 1, 'settings.whatsapp_agent.unipile_account_id': 1 },
2089
+ ).lean();
2090
+ const map = new Map<string, { tenant_id: string; tenant_name: string }>();
2091
+ for (const t of tenants) {
2092
+ const aid = t.settings?.whatsapp_agent?.unipile_account_id;
2093
+ if (aid) map.set(aid, { tenant_id: String(t._id), tenant_name: t.name });
2094
+ }
2095
+ return map;
2096
+ }
2097
+
2098
+ // GET /admin/baileys/raw-messages
2099
+ fastify.get<{
2100
+ Querystring: { page?: string; limit?: string; tenant_id?: string; from_me?: string; date_from?: string; date_to?: string };
2101
+ }>('/baileys/raw-messages', async (request) => {
2102
+ const page = parseInt(request.query.page || '1', 10);
2103
+ const limit = Math.min(parseInt(request.query.limit || '30', 10), 100);
2104
+ const skip = (page - 1) * limit;
2105
+
2106
+ const db = mongoose.connection.db;
2107
+ if (!db) return { logs: [], total: 0, page, totalPages: 0 };
2108
+
2109
+ const accountMap = await buildAccountTenantMap();
2110
+
2111
+ const filter: Record<string, unknown> = {};
2112
+
2113
+ // Filter by tenant → find matching accountId
2114
+ if (request.query.tenant_id) {
2115
+ const accountIds: string[] = [];
2116
+ for (const [aid, info] of accountMap) {
2117
+ if (info.tenant_id === request.query.tenant_id) accountIds.push(aid);
2118
+ }
2119
+ filter.accountId = { $in: accountIds };
2120
+ }
2121
+
2122
+ if (request.query.from_me === 'true') filter['key.fromMe'] = true;
2123
+ else if (request.query.from_me === 'false') filter['key.fromMe'] = false;
2124
+
2125
+ if (request.query.date_from || request.query.date_to) {
2126
+ const dateFilter: Record<string, Date> = {};
2127
+ if (request.query.date_from) dateFilter.$gte = new Date(request.query.date_from);
2128
+ if (request.query.date_to) dateFilter.$lte = new Date(request.query.date_to + 'T23:59:59.999Z');
2129
+ filter.createdAt = dateFilter;
2130
+ }
2131
+
2132
+ const col = db.collection('baileys_raw_messages');
2133
+ const [docs, total] = await Promise.all([
2134
+ col.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit).toArray(),
2135
+ col.countDocuments(filter),
2136
+ ]);
2137
+
2138
+ const logs = docs.map(doc => ({
2139
+ ...doc,
2140
+ tenant_name: accountMap.get(doc.accountId)?.tenant_name || '-',
2141
+ tenant_id: accountMap.get(doc.accountId)?.tenant_id || null,
2142
+ }));
2143
+
2144
+ return { logs, total, page, totalPages: Math.ceil(total / limit) };
2145
+ });
2146
+
2147
+ // GET /admin/baileys/message-log
2148
+ fastify.get<{
2149
+ Querystring: { page?: string; limit?: string; tenant_id?: string; action?: string; reason?: string; date_from?: string; date_to?: string };
2150
+ }>('/baileys/message-log', async (request) => {
2151
+ const page = parseInt(request.query.page || '1', 10);
2152
+ const limit = Math.min(parseInt(request.query.limit || '30', 10), 100);
2153
+ const skip = (page - 1) * limit;
2154
+
2155
+ const db = mongoose.connection.db;
2156
+ if (!db) return { logs: [], total: 0, page, totalPages: 0 };
2157
+
2158
+ const accountMap = await buildAccountTenantMap();
2159
+
2160
+ const filter: Record<string, unknown> = {};
2161
+
2162
+ if (request.query.tenant_id) {
2163
+ const accountIds: string[] = [];
2164
+ for (const [aid, info] of accountMap) {
2165
+ if (info.tenant_id === request.query.tenant_id) accountIds.push(aid);
2166
+ }
2167
+ filter.accountId = { $in: accountIds };
2168
+ }
2169
+
2170
+ if (request.query.action) filter.action = request.query.action;
2171
+ if (request.query.reason) filter.reason = { $regex: request.query.reason, $options: 'i' };
2172
+
2173
+ if (request.query.date_from || request.query.date_to) {
2174
+ const dateFilter: Record<string, Date> = {};
2175
+ if (request.query.date_from) dateFilter.$gte = new Date(request.query.date_from);
2176
+ if (request.query.date_to) dateFilter.$lte = new Date(request.query.date_to + 'T23:59:59.999Z');
2177
+ filter.createdAt = dateFilter;
2178
+ }
2179
+
2180
+ const col = db.collection('baileys_message_log');
2181
+ const [docs, total] = await Promise.all([
2182
+ col.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit).toArray(),
2183
+ col.countDocuments(filter),
2184
+ ]);
2185
+
2186
+ const logs = docs.map(doc => ({
2187
+ ...doc,
2188
+ tenant_name: accountMap.get(doc.accountId)?.tenant_name || '-',
2189
+ tenant_id: accountMap.get(doc.accountId)?.tenant_id || null,
2190
+ }));
2191
+
2192
+ return { logs, total, page, totalPages: Math.ceil(total / limit) };
2193
+ });
2194
+
2195
+ // GET /admin/baileys/message-log/stats — quick summary of action counts
2196
+ fastify.get('/baileys/message-log/stats', async () => {
2197
+ const db = mongoose.connection.db;
2198
+ if (!db) return { stats: {} };
2199
+
2200
+ const col = db.collection('baileys_message_log');
2201
+ const pipeline = [
2202
+ { $group: { _id: '$action', count: { $sum: 1 } } },
2203
+ { $sort: { count: -1 as const } },
2204
+ ];
2205
+ const results = await col.aggregate(pipeline).toArray();
2206
+ const stats: Record<string, number> = {};
2207
+ for (const r of results) stats[r._id] = r.count;
2208
+ return { stats };
2209
+ });
2210
+
2211
+ // ─── ElevenLabs Credit Analytics ────────────────────────────
2212
+ // GET /admin/elevenlabs/credits — cross-tenant credit usage analytics
2213
+ fastify.get<{
2214
+ Querystring: { from?: string; to?: string; tenant_id?: string };
2215
+ }>('/elevenlabs/credits', async (request, reply) => {
2216
+ const now = new Date();
2217
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
2218
+ const from = request.query.from ? new Date(request.query.from) : monthStart;
2219
+ const to = request.query.to ? new Date(request.query.to) : now;
2220
+
2221
+ if (isNaN(from.getTime()) || isNaN(to.getTime())) {
2222
+ return reply.status(400).send({ error: 'Invalid date format for from/to' });
2223
+ }
2224
+
2225
+ const tenantFilter = request.query.tenant_id
2226
+ ? { tenant_id: new mongoose.Types.ObjectId(request.query.tenant_id) }
2227
+ : {};
2228
+
2229
+ const [waPerTenant, callPerTenant, waDailyRows, callDailyRows, tenantList, waDailyPerTenantRows, callDailyPerTenantRows] = await Promise.all([
2230
+ // Q1: WA credits per tenant
2231
+ WhatsAppSession.aggregate([
2232
+ { $match: { started_at: { $gte: from, $lte: to }, elevenlabs_cost: { $ne: null, $gt: 0 }, ...tenantFilter } },
2233
+ { $group: { _id: '$tenant_id', wa_credits: { $sum: '$elevenlabs_cost' }, wa_session_count: { $sum: 1 } } },
2234
+ ]),
2235
+
2236
+ // Q2: Call credits per tenant
2237
+ Call.aggregate([
2238
+ { $match: { createdAt: { $gte: from, $lte: to }, cost: { $ne: null, $gt: 0 }, ...tenantFilter } },
2239
+ { $group: { _id: '$tenant_id', call_credits: { $sum: '$cost' }, call_count: { $sum: 1 } } },
2240
+ ]),
2241
+
2242
+ // Q3: WA daily trend
2243
+ WhatsAppSession.aggregate([
2244
+ { $match: { started_at: { $gte: from, $lte: to }, elevenlabs_cost: { $ne: null, $gt: 0 }, ...tenantFilter } },
2245
+ { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$started_at' } }, wa: { $sum: '$elevenlabs_cost' } } },
2246
+ { $sort: { _id: 1 } },
2247
+ ]),
2248
+
2249
+ // Q4: Call daily trend
2250
+ Call.aggregate([
2251
+ { $match: { createdAt: { $gte: from, $lte: to }, cost: { $ne: null, $gt: 0 }, ...tenantFilter } },
2252
+ { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, calls: { $sum: '$cost' } } },
2253
+ { $sort: { _id: 1 } },
2254
+ ]),
2255
+
2256
+ // Q5: Tenant list for dropdown
2257
+ Tenant.find({}).select('name').lean(),
2258
+
2259
+ // Q6: WA daily trend per tenant
2260
+ WhatsAppSession.aggregate([
2261
+ { $match: { started_at: { $gte: from, $lte: to }, elevenlabs_cost: { $ne: null, $gt: 0 }, ...tenantFilter } },
2262
+ { $group: { _id: { date: { $dateToString: { format: '%Y-%m-%d', date: '$started_at' } }, tenant_id: '$tenant_id' }, credits: { $sum: '$elevenlabs_cost' } } },
2263
+ { $sort: { '_id.date': 1 } },
2264
+ ]),
2265
+
2266
+ // Q7: Call daily trend per tenant
2267
+ Call.aggregate([
2268
+ { $match: { createdAt: { $gte: from, $lte: to }, cost: { $ne: null, $gt: 0 }, ...tenantFilter } },
2269
+ { $group: { _id: { date: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, tenant_id: '$tenant_id' }, credits: { $sum: '$cost' } } },
2270
+ { $sort: { '_id.date': 1 } },
2271
+ ]),
2272
+ ]);
2273
+
2274
+ // Build tenant name map
2275
+ const tenantMap = new Map<string, string>();
2276
+ for (const t of tenantList) tenantMap.set(String(t._id), t.name);
2277
+
2278
+ // Merge per-tenant results
2279
+ const perTenantMap = new Map<string, { tenant_id: string; tenant_name: string; wa_credits: number; call_credits: number; total_credits: number; wa_session_count: number; call_count: number }>();
2280
+ for (const row of waPerTenant) {
2281
+ const tid = String(row._id);
2282
+ perTenantMap.set(tid, {
2283
+ tenant_id: tid, tenant_name: tenantMap.get(tid) || 'Bilinmiyor',
2284
+ wa_credits: row.wa_credits, call_credits: 0, total_credits: row.wa_credits,
2285
+ wa_session_count: row.wa_session_count, call_count: 0,
2286
+ });
2287
+ }
2288
+ for (const row of callPerTenant) {
2289
+ const tid = String(row._id);
2290
+ const existing = perTenantMap.get(tid);
2291
+ if (existing) {
2292
+ existing.call_credits = row.call_credits;
2293
+ existing.call_count = row.call_count;
2294
+ existing.total_credits = existing.wa_credits + row.call_credits;
2295
+ } else {
2296
+ perTenantMap.set(tid, {
2297
+ tenant_id: tid, tenant_name: tenantMap.get(tid) || 'Bilinmiyor',
2298
+ wa_credits: 0, call_credits: row.call_credits, total_credits: row.call_credits,
2299
+ wa_session_count: 0, call_count: row.call_count,
2300
+ });
2301
+ }
2302
+ }
2303
+ const per_tenant = [...perTenantMap.values()].sort((a, b) => b.total_credits - a.total_credits);
2304
+
2305
+ // Summary from per_tenant
2306
+ const summary = per_tenant.reduce(
2307
+ (acc, t) => ({
2308
+ total_credits: acc.total_credits + t.total_credits,
2309
+ wa_credits: acc.wa_credits + t.wa_credits,
2310
+ call_credits: acc.call_credits + t.call_credits,
2311
+ wa_session_count: acc.wa_session_count + t.wa_session_count,
2312
+ call_count: acc.call_count + t.call_count,
2313
+ }),
2314
+ { total_credits: 0, wa_credits: 0, call_credits: 0, wa_session_count: 0, call_count: 0 },
2315
+ );
2316
+
2317
+ // Fill daily trend with zeros for missing days
2318
+ const waMap = new Map(waDailyRows.map((r: { _id: string; wa: number }) => [r._id, r.wa]));
2319
+ const callMap = new Map(callDailyRows.map((r: { _id: string; calls: number }) => [r._id, r.calls]));
2320
+ const daily_trend: { date: string; wa: number; calls: number }[] = [];
2321
+ const cursor = new Date(from);
2322
+ cursor.setHours(0, 0, 0, 0);
2323
+ const endDate = new Date(to);
2324
+ endDate.setHours(0, 0, 0, 0);
2325
+ while (cursor <= endDate) {
2326
+ const key = cursor.toISOString().slice(0, 10);
2327
+ daily_trend.push({ date: key, wa: waMap.get(key) || 0, calls: callMap.get(key) || 0 });
2328
+ cursor.setDate(cursor.getDate() + 1);
2329
+ }
2330
+
2331
+ // Build per-tenant daily trends (one key per tenant per day)
2332
+ // Result: array of { date, [tenantName]: credits, ... } for recharts multi-line
2333
+ const activeTenantIds = new Set<string>();
2334
+ const waPTMap = new Map<string, Map<string, number>>(); // date -> tenantId -> credits
2335
+ const callPTMap = new Map<string, Map<string, number>>();
2336
+
2337
+ for (const row of waDailyPerTenantRows) {
2338
+ const { date, tenant_id } = row._id as { date: string; tenant_id: mongoose.Types.ObjectId };
2339
+ const tid = String(tenant_id);
2340
+ activeTenantIds.add(tid);
2341
+ if (!waPTMap.has(date)) waPTMap.set(date, new Map());
2342
+ waPTMap.get(date)!.set(tid, row.credits);
2343
+ }
2344
+ for (const row of callDailyPerTenantRows) {
2345
+ const { date, tenant_id } = row._id as { date: string; tenant_id: mongoose.Types.ObjectId };
2346
+ const tid = String(tenant_id);
2347
+ activeTenantIds.add(tid);
2348
+ if (!callPTMap.has(date)) callPTMap.set(date, new Map());
2349
+ callPTMap.get(date)!.set(tid, row.credits);
2350
+ }
2351
+
2352
+ // Active tenants with names (sorted by total credits)
2353
+ const activeTenants = per_tenant
2354
+ .filter(t => activeTenantIds.has(t.tenant_id))
2355
+ .map(t => ({ id: t.tenant_id, name: t.tenant_name }));
2356
+
2357
+ // Fill daily per-tenant data
2358
+ const wa_daily_per_tenant: Record<string, unknown>[] = [];
2359
+ const call_daily_per_tenant: Record<string, unknown>[] = [];
2360
+ const c2 = new Date(from);
2361
+ c2.setHours(0, 0, 0, 0);
2362
+ while (c2 <= endDate) {
2363
+ const key = c2.toISOString().slice(0, 10);
2364
+ const waDay: Record<string, unknown> = { date: key };
2365
+ const callDay: Record<string, unknown> = { date: key };
2366
+ for (const t of activeTenants) {
2367
+ waDay[t.name] = waPTMap.get(key)?.get(t.id) || 0;
2368
+ callDay[t.name] = callPTMap.get(key)?.get(t.id) || 0;
2369
+ }
2370
+ wa_daily_per_tenant.push(waDay);
2371
+ call_daily_per_tenant.push(callDay);
2372
+ c2.setDate(c2.getDate() + 1);
2373
+ }
2374
+
2375
+ return {
2376
+ summary,
2377
+ daily_trend,
2378
+ per_tenant,
2379
+ wa_daily_per_tenant,
2380
+ call_daily_per_tenant,
2381
+ active_tenants: activeTenants,
2382
+ tenants: tenantList.map(t => ({ _id: String(t._id), name: t.name })),
2383
+ };
2384
+ });
2385
+ }