From 5c7fd95052aea35a4f61950bfae035ba7b7f1c28 Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Tue, 8 Sep 2026 16:04:01 +0330 Subject: [PATCH] first commit v3 initialiazed a temporary repository for darmanet client --- .dockerignore | 13 + .eslintrc.js | 25 + .gitignore | 153 + .nvmrc | 1 + .prettierrc | 4 + Dockerfile_old | 40 + Dockerfile_saman | 55 + LICENSE | 201 + build_n_deploy.sh | 12 + docker-compose.yml | 51 + migration.js | 59 + src/acl/acl.controller.ts | 201 + src/acl/acl.module.ts | 12 + src/acl/acl.service.ts | 456 ++ src/acl/dto/change-staff-role.dto.ts | 8 + src/acl/dto/create-custom-role.dto.ts | 19 + src/acl/dto/create-staff.dto.ts | 43 + src/acl/dto/set-staff-active.dto.ts | 8 + src/acl/dto/update-role-permissions.dto.ts | 14 + src/acl/dto/update-user-overrides.dto.ts | 16 + src/acl/permissions.service.ts | 100 + src/ai-service/ai-service.module.ts | 8 + src/ai-service/ai-service.service.ts | 534 +++ src/ai-v2/ai-v2-admin.controller.ts | 250 + src/ai-v2/ai-v2-ask.mapper.spec.ts | 48 + src/ai-v2/ai-v2-ask.mapper.ts | 118 + src/ai-v2/ai-v2-threads.controller.ts | 71 + src/ai-v2/ai-v2.client.ts | 407 ++ src/ai-v2/ai-v2.exception.ts | 11 + src/ai-v2/ai-v2.module.ts | 14 + src/ai-v2/ai-v2.service.ts | 326 ++ src/ai-v2/dto/common-query.dto.ts | 30 + src/ai-v2/dto/domains.dto.ts | 53 + src/ai-v2/dto/files.dto.ts | 17 + src/ai-v2/dto/points.dto.ts | 116 + src/ai-v2/dto/retrieval.dto.ts | 31 + src/ai-v2/dto/threads.dto.ts | 59 + src/ai-v2/sse.spec.ts | 27 + src/ai-v2/sse.ts | 73 + src/api/admin/admin.controller.ts | 91 + src/api/admin/admin.module.ts | 22 + src/api/admin/admin.service.ts | 333 ++ src/api/admin/dto/create-admin.dto.ts | 15 + src/api/admin/dto/create-new-expert.dto.ts | 46 + src/api/admin/dto/edit-profile.dto.ts | 18 + src/api/admin/dto/update-admin.dto.ts | 4 + src/api/user/dto/user.dto.ts | 76 + src/api/user/user.controller.ts | 116 + src/api/user/user.module.ts | 14 + src/api/user/user.service.ts | 935 ++++ src/app.module.ts | 76 + src/audio/audio-normalization.errors.ts | 6 + src/audio/audio-normalization.service.ts | 216 + src/audio/audio.module.ts | 8 + src/auth/auth.controller.ts | 147 + src/auth/auth.decorator.ts | 4 + src/auth/auth.guard.ts | 62 + src/auth/auth.module.ts | 32 + src/auth/auth.service.ts | 1287 +++++ src/auth/guards/admin.guard.ts | 274 ++ src/auth/guards/api-key-auth.guard.ts | 38 + src/auth/guards/ip-rate-limiter.guard.ts | 48 + src/auth/local.strategy.ts | 17 + src/auth/models/identity.model.ts | 14 + .../business-hours.controller.ts | 156 + src/business-hours/business-hours.module.ts | 24 + src/business-hours/business-hours.service.ts | 422 ++ .../dto/create-business-hours.dto.ts | 190 + .../guards/business-hours.guard.ts | 44 + src/business-hours/status.controller.ts | 100 + src/categories/categories.controller.ts | 26 + src/categories/categories.module.ts | 11 + src/categories/categories.service.ts | 43 + src/categories/dto/create-category.dto.ts | 27 + src/categories/dto/update-category.dto.ts | 4 + .../attachment-session-expert.guard.ts | 27 + .../attachment-session-user.guard.ts | 27 + .../chat-attachments.controller.ts | 202 + .../chat-attachments.module.ts | 27 + .../chat-attachments.service.ts | 330 ++ src/cli.ts | 127 + .../client-management.controller.ts | 58 + .../client-management.module.ts | 12 + .../client-management.service.ts | 54 + src/client-management/dto/clients.dto.ts | 19 + src/common/decorators/Identity.decorator.ts | 20 + src/common/decorators/permission.decorator.ts | 8 + src/common/decorators/role.decorator.ts | 4 + .../skip-admin-rate-limit.decorator.ts | 4 + src/common/dto/base-response.dto.ts | 77 + src/common/dto/forgetPassword.dto.ts | 8 + src/common/dto/login.dto.ts | 70 + src/common/dto/resetPassword.dto.ts | 12 + src/common/filters/all-exceptions.filter.ts | 30 + .../filters/ws-all-exceptions.filter.ts | 54 + src/common/helpers/redis.service.ts | 66 + .../helpers/structured-logger.service.ts | 204 + src/common/helpers/versioning.helper.ts | 46 + .../security-headers.middleware.ts | 18 + .../middlewares/swagger-auth.middleware.ts | 53 + src/common/services/audit-log.service.ts | 201 + src/common/tools/encryption-helper.ts | 39 + src/common/tools/time-helper.ts | 95 + src/common/types/categories.type.ts | 12 + .../expert-prepared-message-category.type.ts | 24 + src/common/types/expert.type.ts | 4 + src/common/types/func.type.ts | 6 + src/common/types/permissions.catalog.ts | 153 + src/common/types/react.type.ts | 5 + src/common/types/role.type.ts | 22 + src/common/types/sender.type.ts | 5 + src/conversation/conversation.controller.ts | 241 + src/conversation/conversation.module.ts | 12 + src/conversation/conversation.service.ts | 643 +++ src/conversation/dto/admin.dto.ts | 39 + .../dto/create-conversation.dto.ts | 1 + .../dto/update-conversation.dto.ts | 4 + src/database/database.module.ts | 149 + src/database/model/admin.model.ts | 57 + src/database/model/audit-log.model.ts | 74 + src/database/model/botReact.model.ts | 42 + src/database/model/business-hours.model.ts | 69 + src/database/model/categories.model.ts | 29 + .../model/chat-message-attachment.model.ts | 52 + src/database/model/chat.model.ts | 39 + src/database/model/client.model.ts | 31 + src/database/model/dictionaries.model.ts | 77 + .../model/dictionary-file-asset.model.ts | 34 + .../model/expert-prepared-message.model.ts | 54 + src/database/model/externalTokens.model.ts | 42 + src/database/model/fileUpload.model.ts | 34 + src/database/model/installment.model.ts | 91 + src/database/model/messages.model.ts | 26 + src/database/model/policy.model.ts | 105 + .../model/questionModification.model.ts | 56 + src/database/model/questions.model.ts | 44 + src/database/model/rate.model.ts | 20 + src/database/model/reassigned-logs.model.ts | 33 + src/database/model/sessions.model.ts | 125 + src/database/model/staff-role.model.ts | 33 + .../model/user-insurance-snapshot.model.ts | 35 + src/database/model/user.model.ts | 37 + src/database/model/users-base.model.ts | 76 + src/dictionaries/dictionaries.controller.ts | 415 ++ src/dictionaries/dictionaries.module.ts | 21 + src/dictionaries/dictionaries.service.ts | 1536 ++++++ .../dictionary-ai-sync.service.ts | 660 +++ .../dictionary-file-assets.controller.ts | 89 + .../dictionary-file-assets.service.ts | 71 + src/dictionaries/dto/ai-sync.dto.ts | 41 + src/dictionaries/dto/create-dictionary.dto.ts | 150 + src/dictionaries/dto/update-dictionary.dto.ts | 82 + .../dto/create-expert-prepared-message.dto.ts | 59 + .../dto/expert-prepared-messages-query.dto.ts | 23 + ...list-expert-prepared-messages-query.dto.ts | 30 + .../reorder-expert-prepared-messages.dto.ts | 32 + .../dto/update-expert-prepared-message.dto.ts | 6 + ...pert-prepared-messages-admin.controller.ts | 126 + ...ert-prepared-messages-expert.controller.ts | 72 + .../expert-prepared-messages.module.ts | 29 + .../expert-prepared-messages.service.ts | 263 + src/externals/inquiries/inquiries.module.ts | 7 + src/externals/inquiries/inquiries.service.ts | 4 + .../dto/installment-response.dto.ts | 25 + .../dto/policy-response.dto.ts | 39 + .../saman-insurance/saman-insurance.module.ts | 20 + .../saman-insurance.service.ts | 381 ++ src/externals/sms/saman.service.ts | 90 + src/externals/sms/sms.module.ts | 11 + src/externals/sms/sms.service.ts | 4 + src/externals/sso/sso.module.ts | 10 + src/externals/sso/sso.service.ts | 149 + src/main.ts | 140 + src/policies/policies.controller.ts | 68 + src/policies/policies.module.ts | 34 + src/policies/policies.service.ts | 593 +++ src/reports/dto/create-report.dto.ts | 1 + src/reports/dto/update-report.dto.ts | 4 + src/reports/reports.controller.ts | 366 ++ src/reports/reports.module.ts | 12 + src/reports/reports.service.ts | 1631 +++++++ src/scripts/redis-normalize.ts | 178 + src/socket/support-management/chat.gateway.ts | 1661 +++++++ src/socket/support-management/chat.service.ts | 4218 +++++++++++++++++ .../dto/create-support-management.dto.ts | 8 + .../dto/eventPayloads.dto.ts | 294 ++ .../dto/support-events.dto.ts | 63 + .../dto/update-support-management.dto.ts | 3 + .../entities/support-management.entity.ts | 1 + .../support-management.gateway.spec.ts | 19 + .../support-management.gateway.ts | 584 +++ .../support-management.module.ts | 16 + .../support-management.service.spec.ts | 18 + .../support-management.service.ts | 24 + src/storage/key-builder.helper.ts | 68 + src/storage/storage.constants.ts | 96 + src/storage/storage.errors.ts | 7 + src/storage/storage.module.ts | 16 + src/storage/storage.service.ts | 217 + src/storage/storage.types.ts | 5 + src/upload/upload.controller.ts | 100 + src/upload/upload.module.ts | 40 + src/upload/upload.service.ts | 134 + src/upload/uploadFile.dto.ts | 11 + .../dto/create-user-management.dto.ts | 1 + .../dto/find-experts-filter.dto.ts | 47 + .../dto/update-user-management.dto.ts | 6 + .../user-management.controller.ts | 250 + src/user-management/user-management.module.ts | 12 + .../user-management.service.ts | 1063 +++++ src/widget/dto/validate-session.dto.ts | 28 + src/widget/dto/widget-script.dto.ts | 16 + src/widget/widget.controller.ts | 59 + src/widget/widget.module.ts | 12 + src/widget/widget.service.ts | 283 ++ webpack.config.js | 41 + 216 files changed, 30050 insertions(+) create mode 100644 .dockerignore create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 .prettierrc create mode 100644 Dockerfile_old create mode 100644 Dockerfile_saman create mode 100644 LICENSE create mode 100644 build_n_deploy.sh create mode 100644 docker-compose.yml create mode 100644 migration.js create mode 100644 src/acl/acl.controller.ts create mode 100644 src/acl/acl.module.ts create mode 100644 src/acl/acl.service.ts create mode 100644 src/acl/dto/change-staff-role.dto.ts create mode 100644 src/acl/dto/create-custom-role.dto.ts create mode 100644 src/acl/dto/create-staff.dto.ts create mode 100644 src/acl/dto/set-staff-active.dto.ts create mode 100644 src/acl/dto/update-role-permissions.dto.ts create mode 100644 src/acl/dto/update-user-overrides.dto.ts create mode 100644 src/acl/permissions.service.ts create mode 100644 src/ai-service/ai-service.module.ts create mode 100644 src/ai-service/ai-service.service.ts create mode 100644 src/ai-v2/ai-v2-admin.controller.ts create mode 100644 src/ai-v2/ai-v2-ask.mapper.spec.ts create mode 100644 src/ai-v2/ai-v2-ask.mapper.ts create mode 100644 src/ai-v2/ai-v2-threads.controller.ts create mode 100644 src/ai-v2/ai-v2.client.ts create mode 100644 src/ai-v2/ai-v2.exception.ts create mode 100644 src/ai-v2/ai-v2.module.ts create mode 100644 src/ai-v2/ai-v2.service.ts create mode 100644 src/ai-v2/dto/common-query.dto.ts create mode 100644 src/ai-v2/dto/domains.dto.ts create mode 100644 src/ai-v2/dto/files.dto.ts create mode 100644 src/ai-v2/dto/points.dto.ts create mode 100644 src/ai-v2/dto/retrieval.dto.ts create mode 100644 src/ai-v2/dto/threads.dto.ts create mode 100644 src/ai-v2/sse.spec.ts create mode 100644 src/ai-v2/sse.ts create mode 100644 src/api/admin/admin.controller.ts create mode 100644 src/api/admin/admin.module.ts create mode 100644 src/api/admin/admin.service.ts create mode 100644 src/api/admin/dto/create-admin.dto.ts create mode 100644 src/api/admin/dto/create-new-expert.dto.ts create mode 100644 src/api/admin/dto/edit-profile.dto.ts create mode 100644 src/api/admin/dto/update-admin.dto.ts create mode 100644 src/api/user/dto/user.dto.ts create mode 100644 src/api/user/user.controller.ts create mode 100644 src/api/user/user.module.ts create mode 100644 src/api/user/user.service.ts create mode 100644 src/app.module.ts create mode 100644 src/audio/audio-normalization.errors.ts create mode 100644 src/audio/audio-normalization.service.ts create mode 100644 src/audio/audio.module.ts create mode 100644 src/auth/auth.controller.ts create mode 100644 src/auth/auth.decorator.ts create mode 100644 src/auth/auth.guard.ts create mode 100644 src/auth/auth.module.ts create mode 100644 src/auth/auth.service.ts create mode 100644 src/auth/guards/admin.guard.ts create mode 100644 src/auth/guards/api-key-auth.guard.ts create mode 100644 src/auth/guards/ip-rate-limiter.guard.ts create mode 100644 src/auth/local.strategy.ts create mode 100644 src/auth/models/identity.model.ts create mode 100644 src/business-hours/business-hours.controller.ts create mode 100644 src/business-hours/business-hours.module.ts create mode 100644 src/business-hours/business-hours.service.ts create mode 100644 src/business-hours/dto/create-business-hours.dto.ts create mode 100644 src/business-hours/guards/business-hours.guard.ts create mode 100644 src/business-hours/status.controller.ts create mode 100644 src/categories/categories.controller.ts create mode 100644 src/categories/categories.module.ts create mode 100644 src/categories/categories.service.ts create mode 100644 src/categories/dto/create-category.dto.ts create mode 100644 src/categories/dto/update-category.dto.ts create mode 100644 src/chat-attachments/attachment-session-expert.guard.ts create mode 100644 src/chat-attachments/attachment-session-user.guard.ts create mode 100644 src/chat-attachments/chat-attachments.controller.ts create mode 100644 src/chat-attachments/chat-attachments.module.ts create mode 100644 src/chat-attachments/chat-attachments.service.ts create mode 100644 src/cli.ts create mode 100644 src/client-management/client-management.controller.ts create mode 100644 src/client-management/client-management.module.ts create mode 100644 src/client-management/client-management.service.ts create mode 100644 src/client-management/dto/clients.dto.ts create mode 100644 src/common/decorators/Identity.decorator.ts create mode 100644 src/common/decorators/permission.decorator.ts create mode 100644 src/common/decorators/role.decorator.ts create mode 100644 src/common/decorators/skip-admin-rate-limit.decorator.ts create mode 100644 src/common/dto/base-response.dto.ts create mode 100644 src/common/dto/forgetPassword.dto.ts create mode 100644 src/common/dto/login.dto.ts create mode 100644 src/common/dto/resetPassword.dto.ts create mode 100644 src/common/filters/all-exceptions.filter.ts create mode 100644 src/common/filters/ws-all-exceptions.filter.ts create mode 100644 src/common/helpers/redis.service.ts create mode 100644 src/common/helpers/structured-logger.service.ts create mode 100644 src/common/helpers/versioning.helper.ts create mode 100644 src/common/middlewares/security-headers.middleware.ts create mode 100644 src/common/middlewares/swagger-auth.middleware.ts create mode 100644 src/common/services/audit-log.service.ts create mode 100644 src/common/tools/encryption-helper.ts create mode 100644 src/common/tools/time-helper.ts create mode 100644 src/common/types/categories.type.ts create mode 100644 src/common/types/expert-prepared-message-category.type.ts create mode 100644 src/common/types/expert.type.ts create mode 100644 src/common/types/func.type.ts create mode 100644 src/common/types/permissions.catalog.ts create mode 100644 src/common/types/react.type.ts create mode 100644 src/common/types/role.type.ts create mode 100644 src/common/types/sender.type.ts create mode 100644 src/conversation/conversation.controller.ts create mode 100644 src/conversation/conversation.module.ts create mode 100644 src/conversation/conversation.service.ts create mode 100644 src/conversation/dto/admin.dto.ts create mode 100644 src/conversation/dto/create-conversation.dto.ts create mode 100644 src/conversation/dto/update-conversation.dto.ts create mode 100644 src/database/database.module.ts create mode 100644 src/database/model/admin.model.ts create mode 100644 src/database/model/audit-log.model.ts create mode 100644 src/database/model/botReact.model.ts create mode 100644 src/database/model/business-hours.model.ts create mode 100644 src/database/model/categories.model.ts create mode 100644 src/database/model/chat-message-attachment.model.ts create mode 100644 src/database/model/chat.model.ts create mode 100644 src/database/model/client.model.ts create mode 100644 src/database/model/dictionaries.model.ts create mode 100644 src/database/model/dictionary-file-asset.model.ts create mode 100644 src/database/model/expert-prepared-message.model.ts create mode 100644 src/database/model/externalTokens.model.ts create mode 100644 src/database/model/fileUpload.model.ts create mode 100644 src/database/model/installment.model.ts create mode 100644 src/database/model/messages.model.ts create mode 100644 src/database/model/policy.model.ts create mode 100644 src/database/model/questionModification.model.ts create mode 100644 src/database/model/questions.model.ts create mode 100644 src/database/model/rate.model.ts create mode 100644 src/database/model/reassigned-logs.model.ts create mode 100644 src/database/model/sessions.model.ts create mode 100644 src/database/model/staff-role.model.ts create mode 100644 src/database/model/user-insurance-snapshot.model.ts create mode 100644 src/database/model/user.model.ts create mode 100644 src/database/model/users-base.model.ts create mode 100644 src/dictionaries/dictionaries.controller.ts create mode 100644 src/dictionaries/dictionaries.module.ts create mode 100644 src/dictionaries/dictionaries.service.ts create mode 100644 src/dictionaries/dictionary-ai-sync.service.ts create mode 100644 src/dictionaries/dictionary-file-assets.controller.ts create mode 100644 src/dictionaries/dictionary-file-assets.service.ts create mode 100644 src/dictionaries/dto/ai-sync.dto.ts create mode 100644 src/dictionaries/dto/create-dictionary.dto.ts create mode 100644 src/dictionaries/dto/update-dictionary.dto.ts create mode 100644 src/expert-prepared-messages/dto/create-expert-prepared-message.dto.ts create mode 100644 src/expert-prepared-messages/dto/expert-prepared-messages-query.dto.ts create mode 100644 src/expert-prepared-messages/dto/list-expert-prepared-messages-query.dto.ts create mode 100644 src/expert-prepared-messages/dto/reorder-expert-prepared-messages.dto.ts create mode 100644 src/expert-prepared-messages/dto/update-expert-prepared-message.dto.ts create mode 100644 src/expert-prepared-messages/expert-prepared-messages-admin.controller.ts create mode 100644 src/expert-prepared-messages/expert-prepared-messages-expert.controller.ts create mode 100644 src/expert-prepared-messages/expert-prepared-messages.module.ts create mode 100644 src/expert-prepared-messages/expert-prepared-messages.service.ts create mode 100644 src/externals/inquiries/inquiries.module.ts create mode 100644 src/externals/inquiries/inquiries.service.ts create mode 100644 src/externals/saman-insurance/dto/installment-response.dto.ts create mode 100644 src/externals/saman-insurance/dto/policy-response.dto.ts create mode 100644 src/externals/saman-insurance/saman-insurance.module.ts create mode 100644 src/externals/saman-insurance/saman-insurance.service.ts create mode 100644 src/externals/sms/saman.service.ts create mode 100644 src/externals/sms/sms.module.ts create mode 100644 src/externals/sms/sms.service.ts create mode 100644 src/externals/sso/sso.module.ts create mode 100644 src/externals/sso/sso.service.ts create mode 100644 src/main.ts create mode 100644 src/policies/policies.controller.ts create mode 100644 src/policies/policies.module.ts create mode 100644 src/policies/policies.service.ts create mode 100644 src/reports/dto/create-report.dto.ts create mode 100644 src/reports/dto/update-report.dto.ts create mode 100644 src/reports/reports.controller.ts create mode 100644 src/reports/reports.module.ts create mode 100644 src/reports/reports.service.ts create mode 100644 src/scripts/redis-normalize.ts create mode 100644 src/socket/support-management/chat.gateway.ts create mode 100644 src/socket/support-management/chat.service.ts create mode 100644 src/socket/support-management/dto/create-support-management.dto.ts create mode 100644 src/socket/support-management/dto/eventPayloads.dto.ts create mode 100644 src/socket/support-management/dto/support-events.dto.ts create mode 100644 src/socket/support-management/dto/update-support-management.dto.ts create mode 100644 src/socket/support-management/entities/support-management.entity.ts create mode 100644 src/socket/support-management/support-management.gateway.spec.ts create mode 100644 src/socket/support-management/support-management.gateway.ts create mode 100644 src/socket/support-management/support-management.module.ts create mode 100644 src/socket/support-management/support-management.service.spec.ts create mode 100644 src/socket/support-management/support-management.service.ts create mode 100644 src/storage/key-builder.helper.ts create mode 100644 src/storage/storage.constants.ts create mode 100644 src/storage/storage.errors.ts create mode 100644 src/storage/storage.module.ts create mode 100644 src/storage/storage.service.ts create mode 100644 src/storage/storage.types.ts create mode 100644 src/upload/upload.controller.ts create mode 100644 src/upload/upload.module.ts create mode 100644 src/upload/upload.service.ts create mode 100644 src/upload/uploadFile.dto.ts create mode 100644 src/user-management/dto/create-user-management.dto.ts create mode 100644 src/user-management/dto/find-experts-filter.dto.ts create mode 100644 src/user-management/dto/update-user-management.dto.ts create mode 100644 src/user-management/user-management.controller.ts create mode 100644 src/user-management/user-management.module.ts create mode 100644 src/user-management/user-management.service.ts create mode 100644 src/widget/dto/validate-session.dto.ts create mode 100644 src/widget/dto/widget-script.dto.ts create mode 100644 src/widget/widget.controller.ts create mode 100644 src/widget/widget.module.ts create mode 100644 src/widget/widget.service.ts create mode 100644 webpack.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3eba654 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +npm-debug.log +Dockerfile* +docker-compose.yml +.git +.gitignore +*.md +*.log +*.env +*.test.js +test/ +dist/ + diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..259de13 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fbcbe4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,153 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* +docker-compose-*.yml +Dockerfile +*.env + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +# .env +# .env.development.local +# .env.test.local +# .env.production.local +# .env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +*.csv + +/uploads + +/csv_files + +*.md + +ERROR_EVENTS.ts + +FRONTEND_ERROR_HANDLING_EXAMPLE.ts + +*.html + +*.json +README.md + +/scripts +/public \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b393560 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +23 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..dcb7279 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/Dockerfile_old b/Dockerfile_old new file mode 100644 index 0000000..38c15f4 --- /dev/null +++ b/Dockerfile_old @@ -0,0 +1,40 @@ +# Stage 1: Build the application +FROM node:20 AS build + +# Set working directory +WORKDIR /app + +# Copy package.json and package-lock.json for caching npm install +COPY package*.json ./ + +# Install dependencies (use --frozen-lockfile for npm 7+) +RUN npm install + +# Copy the rest of the app +COPY . . + +# Build the NestJS application +RUN npm run build + +# Stage 2: Production environment +FROM node:20-slim AS production + +# Set working directory +WORKDIR /app + +# Copy only necessary files from the build stage +COPY --from=build /app/package*.json ./ +COPY --from=build /app/dist ./dist + +# Install production dependencies only +RUN npm ci --only=production + +# Expose the app port (default for NestJS) +EXPOSE 3000 + +# Set NODE_ENV to production +ENV NODE_ENV=production + +# Run the application +CMD ["node", "dist/main"] + diff --git a/Dockerfile_saman b/Dockerfile_saman new file mode 100644 index 0000000..8dde42e --- /dev/null +++ b/Dockerfile_saman @@ -0,0 +1,55 @@ +# Use the official Node.js image. +FROM docker.si24.ir/node:20-alpine AS builder + +# Create and change to the app directory. +WORKDIR /app + +# Copy application dependency manifests to the container image. +COPY package*.json ./ + +# Specify the non-root user and set ownership (with verbose mode) +RUN adduser -D ittalie +RUN chown -Rv ittalie:ittalie /app + +# Switch to the non-root user +USER ittalie + +# Install dependencies without running scripts +RUN npm config set registry https://repo.si24.ir/repository/npm/ +RUN npm ci --ignore-scripts -d + +# Copy only the necessary source code +COPY src/ ./src/ +COPY tsconfig.json ./ +COPY nest-cli.json ./ +COPY public ./public/ + +# Build the NestJS application. +RUN npm run build + +# --- Production Stage --- +FROM docker.si24.ir/node:22-alpine + +# Create and change to the app directory. +WORKDIR /app + +# Copy package.json and package-lock.json for production dependencies. +COPY package*.json ./ + +# Specify the non-root user and set ownership (with verbose mode) +RUN adduser -D ittalie +RUN chown -Rv ittalie:ittalie /app + +# Switch to the non-root user +USER ittalie + +# Install dependencies without running scripts +RUN npm config set registry https://repo.si24.ir/repository/npm/ +RUN npm ci --ignore-scripts -d + + +# Copy the built application from the builder stage. +COPY --from=builder /app/dist ./dist + +# Run the web service on container startup. +CMD ["npm", "run", "start:prod"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/build_n_deploy.sh b/build_n_deploy.sh new file mode 100644 index 0000000..7e3c0c9 --- /dev/null +++ b/build_n_deploy.sh @@ -0,0 +1,12 @@ +git pull + +docker compose up -d --build + +docker run --rm \ + -e SONAR_HOST_URL="https://sq.ittalie.ir" \ + -e SONAR_TOKEN="sqp_4ec3cdc76b089f4c5b4575ac7c5b4a25ea3f6930" \ + -v "$(pwd):/usr/src" \ + sonarsource/sonar-scanner-cli \ + -Dsonar.projectKey=chatbot-v2-api \ + -Dsonar.sources=. + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..32d2abf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +version: "3.8" + +services: + api: + build: + context: . + dockerfile: Dockerfile + container_name: chatbot-v2-api + environment: + - TZ=Asia/Tehran + ports: + - "4040:8585" + env_file: + - .local.env + networks: + - chatbot-v2 + restart: unless-stopped + volumes: + - chatbot-v2-api:/usr/src/uploads + - ./api-static:/usr/src/src/static + - /etc/localtime:/etc/localtime:ro + #- /etc/timezone:/etc/timezone:ro + deploy: + resources: + limits: + cpus: '1' + memory: '512M' + + redis: + image: redis:7.2-alpine + container_name: chatbot-v2-redis + volumes: + - chatbot-v2_redis_data:/data + ports: + - "4041:6379" + command: > + redis-server --appendonly yes --save 60 1 + restart: unless-stopped + networks: + - chatbot-v2 + + +networks: + chatbot-v2: + driver: bridge + +volumes: + chatbot-v2-api: + name: chatbot-v2-api + chatbot-v2_redis_data: + name: chatbot-v2_redis_data diff --git a/migration.js b/migration.js new file mode 100644 index 0000000..6d95a1f --- /dev/null +++ b/migration.js @@ -0,0 +1,59 @@ +const { MongoClient } = require('mongodb'); + +async function migrateUsers() { + const v1_uri = 'mongodb://localhost:27017/chatbot-parsian'; + const v2_uri = 'mongodb://localhost:27017/chatbot-v2-api'; + + const clientV1 = new MongoClient(v1_uri); + const clientV2 = new MongoClient(v2_uri); + + try { + await clientV1.connect(); + await clientV2.connect(); + + const dbV1 = clientV1.db(); + const dbV2 = clientV2.db(); + + const usersV1 = dbV1.collection('users'); + const usersV2 = dbV2.collection('users'); // Assuming the collection name is also 'users' in v2 + + const cursor = usersV1.find({}); + const usersToMigrate = []; + + for await (const userV1 of cursor) { + const userV2 = { + mobile: userV1.identity, + username: userV1.identity, + role: userV1.userType || 'user', // Default to 'user' if not specified + nationalCode: '', + otp: null, + createdAt: userV1.createdAt || new Date(), + updatedAt: userV1.updatedAt || new Date(), + family: '', + name: '', + birthDate: '', + address: '', + email: '', + otpAttempts: 0, + otpCreatedAt: null, + // Add any other fields from v1 that map directly to v2, or set default values for new v2 fields + }; + usersToMigrate.push(userV2); + } + + if (usersToMigrate.length > 0) { + await usersV2.insertMany(usersToMigrate); + console.log(`Successfully migrated ${usersToMigrate.length} users.`); + } else { + console.log('No users found to migrate.'); + } + + } catch (error) { + console.error('Error during migration:', error); + } finally { + await clientV1.close(); + await clientV2.close(); + } +} + +migrateUsers(); \ No newline at end of file diff --git a/src/acl/acl.controller.ts b/src/acl/acl.controller.ts new file mode 100644 index 0000000..b2d621b --- /dev/null +++ b/src/acl/acl.controller.ts @@ -0,0 +1,201 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { AuditLogModel } from 'src/database/model/audit-log.model'; +import { AclService } from './acl.service'; +import { ChangeStaffRoleDto } from './dto/change-staff-role.dto'; +import { CreateCustomRoleDto } from './dto/create-custom-role.dto'; +import { CreateStaffDto } from './dto/create-staff.dto'; +import { SetStaffActiveDto } from './dto/set-staff-active.dto'; +import { UpdateRolePermissionsDto } from './dto/update-role-permissions.dto'; +import { UpdateUserOverridesDto } from './dto/update-user-overrides.dto'; + +@ApiTags('ACL (Owner)') +@Controller('acl') +@UseGuards(AdminGuard) +@ApiBearerAuth() +export class AclController { + constructor( + private readonly aclService: AclService, + @InjectModel(AuditLogModel.name) + private readonly auditLogModel: Model, + ) {} + + @Get('permissions/catalog') + @Permissions( + Permission.ProfileRead, // any authenticated staff can read catalog for UI; mutations remain Owner-only + ) + @ApiOperation({ summary: 'List assignable permission keys' }) + getCatalog() { + return this.aclService.listPermissionCatalog(); + } + + @Get('roles') + @ApiOperation({ summary: 'List role templates (Owner)' }) + async listRoles(@AdminIdentity() identity: any) { + await this.aclService.assertActorIsOwner(identity.userData._id); + return this.aclService.listRoles(); + } + + @Get('roles/:name') + @ApiOperation({ summary: 'Get one role template (Owner)' }) + async getRole(@AdminIdentity() identity: any, @Param('name') name: string) { + await this.aclService.assertActorIsOwner(identity.userData._id); + return this.aclService.getRole(name); + } + + @Patch('roles/:name') + @ApiOperation({ summary: 'Update role template permissions (Owner)' }) + async updateRole( + @AdminIdentity() identity: any, + @Param('name') name: string, + @Body() dto: UpdateRolePermissionsDto, + @Req() req: any, + ) { + return this.aclService.updateRolePermissions( + identity.userData._id, + name, + dto, + req, + ); + } + + @Post('roles') + @ApiOperation({ summary: 'Create custom role (Owner)' }) + async createRole( + @AdminIdentity() identity: any, + @Body() dto: CreateCustomRoleDto, + @Req() req: any, + ) { + return this.aclService.createCustomRole(identity.userData._id, dto, req); + } + + @Delete('roles/:name') + @ApiOperation({ summary: 'Delete custom role (Owner); blocked if users assigned' }) + async deleteRole( + @AdminIdentity() identity: any, + @Param('name') name: string, + @Req() req: any, + ) { + return this.aclService.deleteCustomRole(identity.userData._id, name, req); + } + + @Get('users/:userId/permissions') + @ApiOperation({ summary: 'Get effective permissions for a staff user (Owner)' }) + async getUserPermissions( + @AdminIdentity() identity: any, + @Param('userId') userId: string, + ) { + await this.aclService.assertActorIsOwner(identity.userData._id); + return this.aclService.getUserEffectivePermissions(userId); + } + + @Patch('users/:userId/overrides') + @ApiOperation({ summary: 'Set permission grants/denies for a staff user (Owner)' }) + async updateOverrides( + @AdminIdentity() identity: any, + @Param('userId') userId: string, + @Body() dto: UpdateUserOverridesDto, + @Req() req: any, + ) { + return this.aclService.updateUserOverrides( + identity.userData._id, + userId, + dto, + req, + ); + } + + @Patch('users/:userId/role') + @ApiOperation({ + summary: 'Change staff role (Owner); clears grants/denies', + }) + async changeRole( + @AdminIdentity() identity: any, + @Param('userId') userId: string, + @Body() dto: ChangeStaffRoleDto, + @Req() req: any, + ) { + return this.aclService.changeStaffRole( + identity.userData._id, + userId, + dto, + req, + ); + } + + @Post('staff') + @ApiOperation({ + summary: + 'Create staff (admin/supervisor/expert/custom). Requires matching create permission; Owner always allowed.', + }) + async createStaff( + @AdminIdentity() identity: any, + @Body() dto: CreateStaffDto, + @Req() req: any, + ) { + return this.aclService.createStaff(identity.userData._id, dto, req); + } + + @Patch('staff/:userId/active') + @ApiOperation({ + summary: + 'Activate/deactivate staff. Same capability flags as create for that role.', + }) + async setActive( + @AdminIdentity() identity: any, + @Param('userId') userId: string, + @Body() dto: SetStaffActiveDto, + @Req() req: any, + ) { + return this.aclService.setStaffActive( + identity.userData._id, + userId, + dto.isActive, + req, + ); + } + + @Get('audit-logs') + @ApiOperation({ summary: 'List RBAC/auth audit logs (Owner)' }) + async auditLogs( + @AdminIdentity() identity: any, + @Query('limit') limit = '50', + @Query('skip') skip = '0', + ) { + await this.aclService.assertActorIsOwner(identity.userData._id); + const lim = Math.min(Number(limit) || 50, 200); + const sk = Number(skip) || 0; + const filter = { + action: { + $regex: /^(acl\.|auth\.staff_)/, + }, + }; + const [items, total] = await Promise.all([ + this.auditLogModel + .find(filter) + .sort({ createdAt: -1 }) + .skip(sk) + .limit(lim) + .lean(), + this.auditLogModel.countDocuments(filter), + ]); + return { total, limit: lim, skip: sk, items }; + } +} diff --git a/src/acl/acl.module.ts b/src/acl/acl.module.ts new file mode 100644 index 0000000..446dca4 --- /dev/null +++ b/src/acl/acl.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { AclController } from './acl.controller'; +import { AclService } from './acl.service'; + +@Module({ + imports: [DatabaseModule], + controllers: [AclController], + providers: [AclService], + exports: [AclService], +}) +export class AclModule {} diff --git a/src/acl/acl.service.ts b/src/acl/acl.service.ts new file mode 100644 index 0000000..e620b48 --- /dev/null +++ b/src/acl/acl.service.ts @@ -0,0 +1,456 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectModel } from '@nestjs/mongoose'; +import { createHash } from 'node:crypto'; +import { Model } from 'mongoose'; +import { AuditLogService } from 'src/common/services/audit-log.service'; +import { + assertAssignablePermissions, + createPermissionForRole, + DEFAULT_ROLE_PERMISSIONS, + Permission, +} from 'src/common/types/permissions.catalog'; +import { + isOwnerRole, + isSystemStaffRole, + Role, + SYSTEM_STAFF_ROLES, +} from 'src/common/types/role.type'; +import { AdminModel } from 'src/database/model/admin.model'; +import { StaffRoleModel } from 'src/database/model/staff-role.model'; +import { PermissionsService } from './permissions.service'; +import { CreateStaffDto } from './dto/create-staff.dto'; +import { CreateCustomRoleDto } from './dto/create-custom-role.dto'; +import { UpdateRolePermissionsDto } from './dto/update-role-permissions.dto'; +import { UpdateUserOverridesDto } from './dto/update-user-overrides.dto'; +import { ChangeStaffRoleDto } from './dto/change-staff-role.dto'; + +@Injectable() +export class AclService implements OnModuleInit { + constructor( + @InjectModel(AdminModel.name) + private readonly adminModel: Model, + @InjectModel(StaffRoleModel.name) + private readonly staffRoleModel: Model, + private readonly permissionsService: PermissionsService, + private readonly auditLogService: AuditLogService, + private readonly configService: ConfigService, + ) {} + + async onModuleInit() { + await this.seedSystemRoleTemplates(); + await this.seedOwnerFromEnv(); + } + + private async seedSystemRoleTemplates() { + for (const role of SYSTEM_STAFF_ROLES) { + await this.staffRoleModel.updateOne( + { name: role }, + { + $setOnInsert: { + name: role, + displayName: role.charAt(0).toUpperCase() + role.slice(1), + permissions: DEFAULT_ROLE_PERMISSIONS[role], + isSystem: true, + }, + }, + { upsert: true }, + ); + } + } + + private async seedOwnerFromEnv() { + const existingOwner = await this.adminModel.findOne({ role: Role.Owner }); + if (existingOwner) { + return; + } + + const email = + this.configService.get('OWNER_EMAIL') || process.env.OWNER_EMAIL; + const password = + this.configService.get('OWNER_PASSWORD') || + process.env.OWNER_PASSWORD; + const mobile = + this.configService.get('OWNER_MOBILE') || process.env.OWNER_MOBILE; + + if (!email || !password) { + console.warn( + '[ACL] No Owner in DB and OWNER_EMAIL/OWNER_PASSWORD not set — skipping Owner seed', + ); + return; + } + + const hashedPassword = createHash('sha256').update(password).digest('hex'); + await this.adminModel.create({ + email, + username: email, + password: hashedPassword, + mobile: mobile || undefined, + role: Role.Owner, + name: 'Owner', + family: '', + isActive: true, + permissionGrants: [], + permissionDenies: [], + }); + + await this.auditLogService.log({ + action: 'acl.owner_seeded', + resource: 'Admin', + metadata: { email }, + }); + } + + async assertActorIsOwner(actorId: string) { + const actor = await this.adminModel.findById(actorId).select('role isActive'); + if (!actor?.isActive || !isOwnerRole(actor.role)) { + throw new ForbiddenException('Only Owner can manage ACL'); + } + } + + listPermissionCatalog() { + return { + permissions: Object.values(Permission), + note: 'ACL meta operations are Owner-only and not assignable', + }; + } + + async listRoles() { + return this.staffRoleModel.find().sort({ isSystem: -1, name: 1 }).lean(); + } + + async getRole(name: string) { + const role = await this.staffRoleModel.findOne({ name }).lean(); + if (!role) throw new NotFoundException('role_not_found'); + return role; + } + + async updateRolePermissions( + actorId: string, + roleName: string, + dto: UpdateRolePermissionsDto, + req?: any, + ) { + await this.assertActorIsOwner(actorId); + if (roleName === Role.Owner) { + throw new BadRequestException('Owner is not an editable role template'); + } + + const permissions = assertAssignablePermissions(dto.permissions); + const role = await this.staffRoleModel.findOne({ name: roleName }); + if (!role) throw new NotFoundException('role_not_found'); + + const oldPermissions = [...role.permissions]; + role.permissions = permissions; + if (dto.displayName !== undefined) { + role.displayName = dto.displayName; + } + await role.save(); + + await this.auditLogService.logHttpRequest('acl.role_permissions_updated', req || {}, { + userId: actorId, + resource: 'StaffRole', + resourceId: roleName, + oldValues: { permissions: oldPermissions }, + newValues: { permissions }, + }); + + return role.toObject(); + } + + async createCustomRole(actorId: string, dto: CreateCustomRoleDto, req?: any) { + await this.assertActorIsOwner(actorId); + + const name = dto.name.trim().toLowerCase(); + if (!/^[a-z][a-z0-9_]{1,63}$/.test(name)) { + throw new BadRequestException( + 'Role name must be lowercase alphanumeric/underscore, starting with a letter', + ); + } + if (isOwnerRole(name) || isSystemStaffRole(name) || name === Role.User) { + throw new BadRequestException('Cannot create a role with a reserved name'); + } + + const existing = await this.staffRoleModel.findOne({ name }); + if (existing) throw new BadRequestException('role_already_exists'); + + const permissions = assertAssignablePermissions(dto.permissions); + const role = await this.staffRoleModel.create({ + name, + displayName: dto.displayName || name, + permissions, + isSystem: false, + }); + + await this.auditLogService.logHttpRequest('acl.role_created', req || {}, { + userId: actorId, + resource: 'StaffRole', + resourceId: name, + newValues: { permissions, displayName: role.displayName }, + }); + + return role.toObject(); + } + + async deleteCustomRole(actorId: string, roleName: string, req?: any) { + await this.assertActorIsOwner(actorId); + + const role = await this.staffRoleModel.findOne({ name: roleName }); + if (!role) throw new NotFoundException('role_not_found'); + if (role.isSystem) { + throw new BadRequestException('Cannot delete a system role'); + } + + const usersCount = await this.adminModel.countDocuments({ role: roleName }); + if (usersCount > 0) { + throw new BadRequestException( + `Cannot delete role while ${usersCount} user(s) are assigned; reassign them first`, + ); + } + + await role.deleteOne(); + + await this.auditLogService.logHttpRequest('acl.role_deleted', req || {}, { + userId: actorId, + resource: 'StaffRole', + resourceId: roleName, + }); + + return { deleted: true, name: roleName }; + } + + async getUserEffectivePermissions(userId: string) { + const admin = await this.adminModel + .findById(userId) + .select('role permissionGrants permissionDenies isActive email username') + .lean(); + if (!admin) throw new NotFoundException('staff_not_found'); + + const effective = await this.permissionsService.getEffectivePermissionsForStaff( + admin, + ); + + return { + userId, + role: admin.role, + permissionGrants: admin.permissionGrants || [], + permissionDenies: admin.permissionDenies || [], + effectivePermissions: [...effective], + }; + } + + async updateUserOverrides( + actorId: string, + userId: string, + dto: UpdateUserOverridesDto, + req?: any, + ) { + await this.assertActorIsOwner(actorId); + + const admin = await this.adminModel.findById(userId); + if (!admin) throw new NotFoundException('staff_not_found'); + if (isOwnerRole(admin.role)) { + throw new BadRequestException('Cannot set overrides on Owner'); + } + + const oldValues = { + permissionGrants: admin.permissionGrants || [], + permissionDenies: admin.permissionDenies || [], + }; + + if (dto.permissionGrants !== undefined) { + admin.permissionGrants = assertAssignablePermissions(dto.permissionGrants); + } + if (dto.permissionDenies !== undefined) { + admin.permissionDenies = assertAssignablePermissions(dto.permissionDenies); + } + await admin.save(); + + await this.auditLogService.logHttpRequest('acl.user_overrides_updated', req || {}, { + userId: actorId, + resource: 'Admin', + resourceId: userId, + oldValues, + newValues: { + permissionGrants: admin.permissionGrants, + permissionDenies: admin.permissionDenies, + }, + }); + + return this.getUserEffectivePermissions(userId); + } + + async changeStaffRole( + actorId: string, + userId: string, + dto: ChangeStaffRoleDto, + req?: any, + ) { + await this.assertActorIsOwner(actorId); + + const admin = await this.adminModel.findById(userId); + if (!admin) throw new NotFoundException('staff_not_found'); + if (isOwnerRole(admin.role)) { + throw new BadRequestException('Cannot change Owner role via API'); + } + if (isOwnerRole(dto.role)) { + throw new BadRequestException('Cannot promote to Owner via API'); + } + + const roleExists = await this.staffRoleModel.findOne({ name: dto.role }); + if (!roleExists) throw new NotFoundException('role_not_found'); + + const oldValues = { + role: admin.role, + permissionGrants: admin.permissionGrants || [], + permissionDenies: admin.permissionDenies || [], + }; + + admin.role = dto.role as Role; + admin.permissionGrants = []; + admin.permissionDenies = []; + await admin.save(); + + await this.auditLogService.logHttpRequest('acl.staff_role_changed', req || {}, { + userId: actorId, + resource: 'Admin', + resourceId: userId, + oldValues, + newValues: { + role: admin.role, + permissionGrants: [], + permissionDenies: [], + }, + }); + + return this.getUserEffectivePermissions(userId); + } + + async createStaff(actorId: string, dto: CreateStaffDto, req?: any) { + const actor = await this.adminModel.findById(actorId); + if (!actor?.isActive) throw new ForbiddenException('inactive_actor'); + + if (isOwnerRole(dto.role)) { + throw new BadRequestException('Cannot create Owner via API'); + } + + const requiredPerm = createPermissionForRole(dto.role); + if (!requiredPerm) { + // Custom role: only Owner may create users with custom roles for v1 + if (!isOwnerRole(actor.role)) { + throw new ForbiddenException( + 'Only Owner can create staff with custom roles', + ); + } + } else if (!isOwnerRole(actor.role)) { + const ok = await this.permissionsService.hasPermission(actorId, requiredPerm); + if (!ok) { + throw new ForbiddenException(`Missing permission: ${requiredPerm}`); + } + } + + const roleExists = await this.staffRoleModel.findOne({ name: dto.role }); + if (!roleExists) throw new NotFoundException('role_not_found'); + + const existing = await this.adminModel.findOne({ + $or: [{ email: dto.email }, { mobile: dto.mobile }], + }); + if (existing) { + throw new BadRequestException( + 'Staff with this email or mobile already exists', + ); + } + + const hashedPassword = createHash('sha256') + .update(dto.password) + .digest('hex'); + + const created = await this.adminModel.create({ + email: dto.email, + username: dto.email, + password: hashedPassword, + mobile: dto.mobile, + name: dto.name, + family: dto.family, + role: dto.role, + isActive: true, + permissionGrants: [], + permissionDenies: [], + }); + + const { password, ...safe } = created.toObject(); + + await this.auditLogService.logHttpRequest('acl.staff_created', req || {}, { + userId: actorId, + resource: 'Admin', + resourceId: String(created._id), + newValues: { email: dto.email, role: dto.role }, + }); + + return safe; + } + + async setStaffActive( + actorId: string, + targetId: string, + isActive: boolean, + req?: any, + ) { + const actor = await this.adminModel.findById(actorId); + if (!actor?.isActive) throw new ForbiddenException('inactive_actor'); + + const target = await this.adminModel.findById(targetId); + if (!target) throw new NotFoundException('staff_not_found'); + if (isOwnerRole(target.role)) { + throw new BadRequestException('Cannot deactivate Owner via API'); + } + if (String(target._id) === String(actorId)) { + throw new BadRequestException('Cannot deactivate yourself'); + } + + const requiredPerm = createPermissionForRole(target.role); + if (!requiredPerm) { + if (!isOwnerRole(actor.role)) { + throw new ForbiddenException( + 'Only Owner can deactivate staff with custom roles', + ); + } + } else if (!isOwnerRole(actor.role)) { + const ok = await this.permissionsService.hasPermission(actorId, requiredPerm); + if (!ok) { + throw new ForbiddenException(`Missing permission: ${requiredPerm}`); + } + } + + const oldActive = target.isActive; + target.isActive = isActive; + await target.save(); + + await this.auditLogService.logHttpRequest( + isActive ? 'acl.staff_activated' : 'acl.staff_deactivated', + req || {}, + { + userId: actorId, + resource: 'Admin', + resourceId: targetId, + oldValues: { isActive: oldActive }, + newValues: { isActive }, + }, + ); + + const { password, resetToken, ...safe } = target.toObject(); + return safe; + } + + async listAuditLogs(actorId: string, limit = 50, skip = 0) { + await this.assertActorIsOwner(actorId); + // Queried via AuditLogService model indirectly — use inject in controller path + return { limit, skip }; + } +} diff --git a/src/acl/dto/change-staff-role.dto.ts b/src/acl/dto/change-staff-role.dto.ts new file mode 100644 index 0000000..bccf8ff --- /dev/null +++ b/src/acl/dto/change-staff-role.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class ChangeStaffRoleDto { + @ApiProperty({ example: 'supervisor' }) + @IsString() + role: string; +} diff --git a/src/acl/dto/create-custom-role.dto.ts b/src/acl/dto/create-custom-role.dto.ts new file mode 100644 index 0000000..e1279ab --- /dev/null +++ b/src/acl/dto/create-custom-role.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsString, Matches } from 'class-validator'; + +export class CreateCustomRoleDto { + @ApiProperty({ example: 'content_manager' }) + @IsString() + @Matches(/^[a-z][a-z0-9_]{1,63}$/) + name: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + displayName?: string; + + @ApiProperty({ type: [String], example: ['dictionaries.read', 'dictionaries.write'] }) + @IsArray() + @IsString({ each: true }) + permissions: string[]; +} diff --git a/src/acl/dto/create-staff.dto.ts b/src/acl/dto/create-staff.dto.ts new file mode 100644 index 0000000..5aad6f6 --- /dev/null +++ b/src/acl/dto/create-staff.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsEmail, + IsOptional, + IsString, + Matches, + MinLength, +} from 'class-validator'; +import { Role } from 'src/common/types/role.type'; + +export class CreateStaffDto { + @ApiProperty() + @IsEmail() + email: string; + + @ApiProperty() + @IsString() + @MinLength(6) + password: string; + + @ApiProperty() + @IsString() + @Matches(/^[0-9]{11}$/) + mobile: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + family?: string; + + @ApiProperty({ + description: + 'System role (admin|supervisor|expert) or custom role name. Owner cannot be created via API.', + example: Role.Expert, + }) + @IsString() + role: string; +} diff --git a/src/acl/dto/set-staff-active.dto.ts b/src/acl/dto/set-staff-active.dto.ts new file mode 100644 index 0000000..cbda76b --- /dev/null +++ b/src/acl/dto/set-staff-active.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean } from 'class-validator'; + +export class SetStaffActiveDto { + @ApiProperty() + @IsBoolean() + isActive: boolean; +} diff --git a/src/acl/dto/update-role-permissions.dto.ts b/src/acl/dto/update-role-permissions.dto.ts new file mode 100644 index 0000000..a1e4319 --- /dev/null +++ b/src/acl/dto/update-role-permissions.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsString } from 'class-validator'; + +export class UpdateRolePermissionsDto { + @ApiProperty({ type: [String] }) + @IsArray() + @IsString({ each: true }) + permissions: string[]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + displayName?: string; +} diff --git a/src/acl/dto/update-user-overrides.dto.ts b/src/acl/dto/update-user-overrides.dto.ts new file mode 100644 index 0000000..e36f159 --- /dev/null +++ b/src/acl/dto/update-user-overrides.dto.ts @@ -0,0 +1,16 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsString } from 'class-validator'; + +export class UpdateUserOverridesDto { + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + permissionGrants?: string[]; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + permissionDenies?: string[]; +} diff --git a/src/acl/permissions.service.ts b/src/acl/permissions.service.ts new file mode 100644 index 0000000..8bc75f5 --- /dev/null +++ b/src/acl/permissions.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { + ALL_ASSIGNABLE_PERMISSIONS, + Permission, +} from 'src/common/types/permissions.catalog'; +import { isOwnerRole } from 'src/common/types/role.type'; +import { AdminModel } from 'src/database/model/admin.model'; +import { StaffRoleModel } from 'src/database/model/staff-role.model'; + +@Injectable() +export class PermissionsService { + constructor( + @InjectModel(AdminModel.name) + private readonly adminModel: Model, + @InjectModel(StaffRoleModel.name) + private readonly staffRoleModel: Model, + ) {} + + /** + * effective = (role.permissions ∪ grants) − denies + * Owner always has every assignable permission. + */ + computeEffective( + roleName: string, + rolePermissions: string[], + grants: string[] = [], + denies: string[] = [], + ): Set { + if (isOwnerRole(roleName)) { + return new Set(ALL_ASSIGNABLE_PERMISSIONS); + } + + const effective = new Set([ + ...rolePermissions, + ...(grants || []), + ]); + for (const deny of denies || []) { + effective.delete(deny); + } + return effective as Set; + } + + async getEffectivePermissionsForAdminId( + adminId: string, + ): Promise> { + const admin = await this.adminModel + .findById(adminId) + .select('role permissionGrants permissionDenies isActive') + .lean() + .exec(); + + if (!admin || !admin.isActive) { + return new Set(); + } + + return this.getEffectivePermissionsForStaff({ + role: admin.role, + permissionGrants: admin.permissionGrants, + permissionDenies: admin.permissionDenies, + }); + } + + async getEffectivePermissionsForStaff(staff: { + role: string; + permissionGrants?: string[]; + permissionDenies?: string[]; + }): Promise> { + if (isOwnerRole(staff.role)) { + return new Set(ALL_ASSIGNABLE_PERMISSIONS); + } + + const roleDoc = await this.staffRoleModel + .findOne({ name: staff.role }) + .select('permissions') + .lean() + .exec(); + + return this.computeEffective( + staff.role, + roleDoc?.permissions || [], + staff.permissionGrants || [], + staff.permissionDenies || [], + ); + } + + async hasPermission( + adminId: string, + required: Permission | Permission[], + ): Promise { + const effective = await this.getEffectivePermissionsForAdminId(adminId); + const needed = Array.isArray(required) ? required : [required]; + return needed.some((p) => effective.has(p)); + } + + hasAny(effective: Set, required: Permission[]): boolean { + return required.some((p) => effective.has(p)); + } +} diff --git a/src/ai-service/ai-service.module.ts b/src/ai-service/ai-service.module.ts new file mode 100644 index 0000000..0106c4b --- /dev/null +++ b/src/ai-service/ai-service.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { AiServiceService } from './ai-service.service'; + +@Module({ + providers: [AiServiceService], + exports: [AiServiceService], +}) +export class AiServiceModule {} diff --git a/src/ai-service/ai-service.service.ts b/src/ai-service/ai-service.service.ts new file mode 100644 index 0000000..de710b5 --- /dev/null +++ b/src/ai-service/ai-service.service.ts @@ -0,0 +1,534 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import * as FormData from 'form-data'; +import * as fs from 'fs'; +import * as path from 'path'; + +@Injectable() +export class AiServiceService { + /** Headers for every AI HTTP call (`X-API-Key` from `AI_API_KEY`). */ + private aiHeaders(extra: Record = {}): Record { + const headers: Record = { + accept: 'application/json', + ...extra, + }; + const key = process.env.AI_API_KEY?.trim(); + if (key) { + headers['X-API-Key'] = key; + } + return headers; + } + + async ask(body, session) { + try { + if (!session) { + const data = JSON.stringify({ + user_input: body.user_input ?? body.question, + chat_history_raw: body.chat_history_raw ?? [], + user_insurance_data: body.user_insurance_data, + user_installments_data: body.user_installments_data, + }); + let axiosConfig = { + method: 'post', + maxBodyLength: Infinity, + url: process.env.AI_QUERY_URL, + headers: this.aiHeaders({ 'Content-Type': 'application/json' }), + data: data, + }; + const chat = await axios.request(axiosConfig); + const aiResponse = chat.data.response; + return aiResponse; + } else { + let transformedData = body; + let axiosConfig = { + method: 'post', + maxBodyLength: Infinity, + url: process.env.AI_QUERY_URL, + headers: this.aiHeaders({ 'Content-Type': 'application/json' }), + data: transformedData, + }; + const newChat = await axios.request(axiosConfig); + if (!newChat.data && newChat.data.response) throw new Error('AI_ERROR'); + const aiResponse = newChat.data.response; + return aiResponse; + } + } catch (err) { + console.log(err); + if (err.isAxiosError) { + const status = err.response?.status || HttpStatus.INTERNAL_SERVER_ERROR; + const message = err.response?.data || 'Internal Server Error'; + + return new BaseResponseDTO(status, 'ai_service_error', { + error: message, + }); + } + return new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + 'something_wrong', + null, + ); + } + } + + async wrapUp(data) { + try { + let axiosConfig = { + method: 'post', + maxBodyLength: Infinity, + url: process.env.AI_WRAPUP_URL, + headers: this.aiHeaders({ 'Content-Type': 'application/json' }), + data: data, + }; + const wrapup = await axios.request(axiosConfig); + const aiResponse = wrapup.data.title; + return aiResponse; + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async aiUploadTxt(file) { + try { + console.log(`[aiUploadTxt] Attempting to upload file: ${file.originalName}`); + const formData = new FormData(); + const fileStream = fs.createReadStream(file.filePath); + + formData.append('file', fileStream, { + filename: file.originalName, + contentType: file.mimetype + }); + + // Upload the file + const uploadResponse = await axios.post(process.env.AI_UPLOAD_URL, formData, { + headers: this.aiHeaders(formData.getHeaders() as Record), + timeout: 30000 + }); + + console.log(`[aiUploadTxt] File upload response for ${file.originalName}:`, uploadResponse.data); + + // Check if upload was successful + if (uploadResponse.status === 200) { + console.log(`[aiUploadTxt] File ${file.originalName} uploaded successfully. Initiating QA generation.`); + const qaResponse = await axios.post( + `${process.env.AI_QA_GENERATOR_URL}?file_name=${encodeURIComponent(file.originalName)}`, + {}, + { + headers: this.aiHeaders(), + timeout: 120000 // 2 minutes timeout for QA generation + } + ); + + console.log('QA Generation response:', qaResponse.data); + + // Consider any HTTP 200 as success regardless of body shape + if (qaResponse.status === 200) { + return { + success: true, + fileName: file.originalName, + }; + } + throw new Error('QA generation failed'); + } else { + throw new Error('File upload failed'); + } + + } catch (err) { + console.error('Error uploading file to AI service:', err); + + // Handle different types of errors + let status = 500; + let errorData = null; + + if (err.response) { + // The request was made and the server responded with a status code + status = err.response.status; + errorData = err.response.data; + } else if (err.request) { + // The request was made but no response was received + errorData = 'No response received from AI service'; + } else { + // Something happened in setting up the request + errorData = err.message; + } + + throw new BaseResponseDTO(status, errorData, null); + } +} + + async aiUploadCsv(file) { + try { + console.log(`[aiUploadCsv] Attempting to upload file: ${file.originalName}`); + const formData = new FormData(); + formData.append('file', fs.createReadStream(file.filePath), { + filename: file.originalName, + contentType: file.mimetype, + }); + + const axiosConfig = { + method: 'post', + url: process.env.AI_UPLOAD_URL, + headers: this.aiHeaders(formData.getHeaders() as Record), + data: formData, + }; + + const response = await axios.request(axiosConfig); + console.log(`[aiUploadCsv] File upload successful for ${file.originalName}. Response:`, response.data); + return response.data; + } catch (err) { + console.error('[aiUploadCsv] Error uploading file to AI service:', err); + + // Handle different types of errors + let status = 500; + let errorData = null; + + if (err.response) { + // The request was made and the server responded with a status code + status = err.response.status; + errorData = err.response.data; + } else if (err.request) { + // The request was made but no response was received + errorData = 'No response received from AI service'; + } else { + // Something happened in setting up the request + errorData = err.message; + } + + throw new BaseResponseDTO(status, errorData, null); + } + } + +async newCollection(collection_name: string, filename: string, description: string) { + try { + console.log(`[newCollection] Attempting to create new collection: ${collection_name} with filename: ${filename} and description: ${description}`); + + const params = new URLSearchParams(); + params.append('collection_name', collection_name); + params.append('filename', filename); + params.append('description', description); + + const axiosConfig = { + method: 'post', + url: `${process.env.AI_COLLECTION_URL}/new-collection?${params.toString()}`, + headers: this.aiHeaders(), + }; + + const response = await axios.request(axiosConfig); + console.log(`[newCollection] Collection ${collection_name} created successfully. Response:`, response.data); + return response.data; + } catch (err) { + console.error('[newCollection] Error creating new collection:', err); + + // Handle different types of errors + let status = 500; + let errorData = null; + + if (err.response) { + // The request was made and the server responded with a status code + status = err.response.status; + errorData = err.response.data; + } else if (err.request) { + // The request was made but no response was received + errorData = 'No response received from AI service'; + } else { + // Something happened in setting up the request + errorData = err.message; + } + + throw new BaseResponseDTO(status, errorData, null); + } +} + +async getCollections() { + try { + console.log(`[getCollections] Attempting to retrieve all collections.`); + const axiosConfig = { + method: 'get', + url: `${process.env.AI_COLLECTION_URL}/get-collections`, + headers: this.aiHeaders(), + }; + + const response = await axios.request(axiosConfig); + console.log(`[getCollections] Collections retrieved successfully. Response:`, response.data); + return response.data; + } catch (err) { + console.error('[getCollections] Error getting collections:', err); + + // Handle different types of errors + let status = 500; + let errorData = null; + + if (err.response) { + // The request was made and the server responded with a status code + status = err.response.status; + errorData = err.response.data; + } else if (err.request) { + // The request was made but no response was received + errorData = 'No response received from AI service'; + } else { + // Something happened in setting up the request + errorData = err.message; + } + + throw new BaseResponseDTO(status, errorData, null); + } +} + +async updateCollection(collection_name: string, filename: string, description: string) { + try { + console.log(`[updateCollection] Attempting to update collection: ${collection_name} with filename: ${filename} and description: ${description}`); + + const params = new URLSearchParams(); + params.append('collection_name', collection_name); + params.append('filename', filename); + params.append('description', description); + + const axiosConfig = { + method: 'post', + url: `${process.env.AI_COLLECTION_URL}/update-collection?${params.toString()}`, + headers: this.aiHeaders(), + }; + + const response = await axios.request(axiosConfig); + console.log(`[updateCollection] Collection ${collection_name} updated successfully. Response:`, response.data); + return response.data; + } catch (err) { + console.error('[updateCollection] Error updating collection:', err); + + // Handle different types of errors + let status = 500; + let errorData = null; + + if (err.response) { + // The request was made and the server responded with a status code + status = err.response.status; + errorData = err.response.data; + } else if (err.request) { + // The request was made but no response was received + errorData = 'No response received from AI service'; + } else { + // Something happened in setting up the request + errorData = err.message; + } + + throw new BaseResponseDTO(status, errorData, null); + } +} + +async replaceCollection(collection_name: string, filename: string, description?: string) { + try { + console.log(`[replaceCollection] Attempting to replace collection: ${collection_name} with filename: ${filename}${description ? ` and description: ${description}` : ''}`); + const params = new URLSearchParams(); + params.append('collection_name', collection_name); + params.append('filename', filename); + if (description) { + params.append('description', description); + } + + const axiosConfig = { + method: 'post', + url: `${process.env.AI_COLLECTION_URL}/replace-collection?${params.toString()}`, + headers: this.aiHeaders(), + }; + + const response = await axios.request(axiosConfig); + console.log(`[replaceCollection] Collection ${collection_name} replaced successfully. Response:`, response.data); + return response.data; + } catch (err) { + console.error('Error replacing collection:', err); + + // Handle different types of errors + let status = 500; + let errorData = null; + + if (err.response) { + // The request was made and the server responded with a status code + status = err.response.status; + errorData = err.response.data; + } else if (err.request) { + // The request was made but no response was received + errorData = 'No response received from AI service'; + } else { + // Something happened in setting up the request + errorData = err.message; + } + + throw new BaseResponseDTO(status, errorData, null); + } +} + +async deactivateCollection(collection_name: string) { + try { + console.log(`[deactivateCollection] Attempting to deactivate collection: ${collection_name}`); + + const params = new URLSearchParams(); + params.append('collection_name', collection_name); + + const axiosConfig = { + method: 'post', + url: `${process.env.AI_COLLECTION_URL}/deactivate-collection?${params.toString()}`, + headers: this.aiHeaders(), + }; + + const response = await axios.request(axiosConfig); + console.log(`[deactivateCollection] Collection ${collection_name} deactivated successfully. Response:`, response.data); + return response.data; + } catch (err) { + console.error('[deactivateCollection] Error deactivating collection:', err); + + // Handle different types of errors + let status = 500; + let errorData = null; + + if (err.response) { + // The request was made and the server responded with a status code + status = err.response.status; + errorData = err.response.data; + } else if (err.request) { + // The request was made but no response was received + errorData = 'No response received from AI service'; + } else { + // Something happened in setting up the request + errorData = err.message; + } + + throw new BaseResponseDTO(status, errorData, null); + } +} + + private collectionBaseUrl(): string { + return String(process.env.AI_COLLECTION_URL || '').replace(/\/$/, ''); + } + + private throwAiError(err: any): never { + let status = 500; + let errorData: unknown = null; + if (err?.response) { + status = err.response.status; + errorData = err.response.data; + } else if (err?.request) { + errorData = 'No response received from AI service'; + } else { + errorData = err?.message ?? err; + } + throw new BaseResponseDTO(status, errorData as any, null); + } + + async getCollectionsWithDescriptions() { + try { + const response = await axios.get( + `${this.collectionBaseUrl()}/get-collections-with-descriptions`, + { headers: this.aiHeaders(), timeout: 60_000 }, + ); + return response.data; + } catch (err) { + this.throwAiError(err); + } + } + + async getCollectionDescription(collectionName: string) { + try { + const response = await axios.get( + `${this.collectionBaseUrl()}/get-collection-description/${encodeURIComponent(collectionName)}`, + { headers: this.aiHeaders(), timeout: 60_000 }, + ); + return response.data; + } catch (err) { + this.throwAiError(err); + } + } + + async checkCollections() { + try { + const response = await axios.get( + `${this.collectionBaseUrl()}/check-collections`, + { headers: this.aiHeaders(), timeout: 60_000 }, + ); + return response.data; + } catch (err) { + this.throwAiError(err); + } + } + + async exportCollectionPage( + collectionName: string, + page = 1, + pageSize = 50, + ) { + try { + const response = await axios.get( + `${this.collectionBaseUrl()}/sync/export/${encodeURIComponent(collectionName)}`, + { + params: { page, page_size: pageSize }, + headers: this.aiHeaders(), + timeout: 120_000, + }, + ); + return response.data as { + collection: string; + page: number; + page_size: number; + total_items: number; + total_pages: number; + items: Array<{ id: string; q: string; a: string }>; + }; + } catch (err) { + this.throwAiError(err); + } + } + + async createExportItem( + collectionName: string, + body: { q: string; a: string }, + ) { + try { + const response = await axios.post( + `${this.collectionBaseUrl()}/sync/export/${encodeURIComponent(collectionName)}`, + body, + { + headers: this.aiHeaders({ 'Content-Type': 'application/json' }), + timeout: 60_000, + }, + ); + return response.data; + } catch (err) { + this.throwAiError(err); + } + } + + async updateExportItem( + collectionName: string, + itemId: string, + body: { q: string; a: string }, + ) { + try { + const response = await axios.put( + `${this.collectionBaseUrl()}/sync/export/${encodeURIComponent(collectionName)}/${encodeURIComponent(itemId)}`, + body, + { + headers: this.aiHeaders({ 'Content-Type': 'application/json' }), + timeout: 60_000, + }, + ); + return response.data; + } catch (err) { + this.throwAiError(err); + } + } + + async deleteExportItem(collectionName: string, itemId: string) { + try { + const response = await axios.delete( + `${this.collectionBaseUrl()}/sync/export/${encodeURIComponent(collectionName)}/${encodeURIComponent(itemId)}`, + { + headers: this.aiHeaders(), + timeout: 60_000, + }, + ); + return response.data; + } catch (err) { + this.throwAiError(err); + } + } +} diff --git a/src/ai-v2/ai-v2-admin.controller.ts b/src/ai-v2/ai-v2-admin.controller.ts new file mode 100644 index 0000000..a1c2b8d --- /dev/null +++ b/src/ai-v2/ai-v2-admin.controller.ts @@ -0,0 +1,250 @@ +import { + Body, + Controller, + Delete, + Get, + HttpStatus, + Param, + Patch, + Post, + Put, + Query, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { MAX_DICTIONARY_BYTES } from 'src/storage/storage.constants'; +import { AiV2Client } from './ai-v2.client'; +import { AiV2Exception } from './ai-v2.exception'; +import { AiV2Service } from './ai-v2.service'; +import { CursorPageQueryDto } from './dto/common-query.dto'; +import { CreateDomainDto, ListDomainsQueryDto, UpdateDomainDto } from './dto/domains.dto'; +import { ListFilesQueryDto } from './dto/files.dto'; +import { + CountPointsQueryDto, + CreatePointDto, + GetPointQueryDto, + ListPointsQueryDto, + PatchPointPayloadDto, + ReplacePointDto, + SearchPointsQueryDto, +} from './dto/points.dto'; +import { RetrievalQueryDto } from './dto/retrieval.dto'; + +@ApiTags('v2 AI') +@ApiBearerAuth() +@UseGuards(AdminGuard) +@Permissions(Permission.DictionariesRead) +@Controller('ai/v2') +export class AiV2AdminController { + constructor( + private readonly client: AiV2Client, + private readonly aiV2: AiV2Service, + ) {} + + @Get('domains') + @ApiOperation({ summary: 'List tenant domains (AI collections)' }) + async listDomains(@Query() query: ListDomainsQueryDto) { + return this.aiV2.ok(await this.client.listDomains(query.include_disabled)); + } + + @Post('domains') + @Permissions(Permission.DictionariesCreate) + @ApiOperation({ summary: 'Create a tenant domain' }) + @ApiBody({ type: CreateDomainDto }) + async createDomain(@Body() body: CreateDomainDto) { + return this.aiV2.created(await this.client.createDomain(body)); + } + + @Patch('domains/:domain') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ summary: 'Update a tenant domain display name' }) + @ApiParam({ name: 'domain' }) + @ApiBody({ type: UpdateDomainDto }) + async updateDomain( + @Param('domain') domain: string, + @Body() body: UpdateDomainDto, + ) { + return this.aiV2.ok(await this.client.updateDomain(domain, body)); + } + + @Delete('domains/:domain') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ summary: 'Disable a tenant domain' }) + @ApiParam({ name: 'domain' }) + async disableDomain(@Param('domain') domain: string) { + return this.aiV2.ok(await this.client.disableDomain(domain)); + } + + @Post('domains/:domain/enable') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ summary: 'Enable a tenant domain' }) + @ApiParam({ name: 'domain' }) + async enableDomain(@Param('domain') domain: string) { + return this.aiV2.ok(await this.client.enableDomain(domain)); + } + + @Get('domains/:domain/points') + @ApiOperation({ summary: 'List points in a domain' }) + @ApiParam({ name: 'domain' }) + async listDomainPoints( + @Param('domain') domain: string, + @Query() query: CursorPageQueryDto, + ) { + return this.aiV2.ok(await this.client.listDomainPoints(domain, query)); + } + + @Post('files') + @Permissions(Permission.DictionariesCreate) + @ApiOperation({ summary: 'Upload a file into a domain for ingestion' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file', 'domain'], + properties: { + file: { type: 'string', format: 'binary' }, + domain: { type: 'string', example: 'faq' }, + }, + }, + }) + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: MAX_DICTIONARY_BYTES }, + }), + ) + async uploadFile( + @UploadedFile() file: Express.Multer.File, + @Body('domain') domain: string, + ) { + if (!file) { + throw new AiV2Exception(HttpStatus.BAD_REQUEST, 'file_required'); + } + if (!domain?.trim()) { + throw new AiV2Exception(HttpStatus.BAD_REQUEST, 'domain_required'); + } + return this.aiV2.created(await this.client.uploadFile(file, domain.trim())); + } + + @Get('files') + @ApiOperation({ summary: 'List uploaded files' }) + async listFiles(@Query() query: ListFilesQueryDto) { + return this.aiV2.ok(await this.client.listFiles(query)); + } + + @Get('files/:fileId') + @ApiOperation({ summary: 'Get file status / ingestion progress' }) + @ApiParam({ name: 'fileId' }) + async getFile(@Param('fileId') fileId: string) { + return this.aiV2.ok(await this.client.getFile(fileId)); + } + + @Delete('files/:fileId') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ summary: 'Delete a file (soft-deletes its points)' }) + @ApiParam({ name: 'fileId' }) + async deleteFile(@Param('fileId') fileId: string) { + return this.aiV2.ok(await this.client.deleteFile(fileId)); + } + + @Get('files/:fileId/points') + @ApiOperation({ summary: 'List points for a file' }) + @ApiParam({ name: 'fileId' }) + async listFilePoints( + @Param('fileId') fileId: string, + @Query() query: CursorPageQueryDto, + ) { + return this.aiV2.ok(await this.client.listFilePoints(fileId, query)); + } + + @Get('points/count') + @ApiOperation({ summary: 'Count points, optionally filtered by domain and/or file' }) + async countPoints(@Query() query: CountPointsQueryDto) { + return this.aiV2.ok(await this.client.countPoints(query)); + } + + @Get('points/search') + @ApiOperation({ summary: 'Keyword search points (not semantic retrieval)' }) + async searchPoints(@Query() query: SearchPointsQueryDto) { + return this.aiV2.ok(await this.client.searchPoints(query)); + } + + @Get('points') + @ApiOperation({ summary: 'List points for a file_id' }) + @ApiQuery({ name: 'file_id', required: true }) + async listPoints(@Query() query: ListPointsQueryDto) { + return this.aiV2.ok(await this.client.listPoints(query)); + } + + @Post('points') + @Permissions(Permission.DictionariesCreate) + @ApiOperation({ summary: 'Create a point in a file' }) + @ApiBody({ type: CreatePointDto }) + async createPoint(@Body() body: CreatePointDto) { + return this.aiV2.created(await this.client.createPoint(body)); + } + + @Get('points/:pointId') + @ApiOperation({ summary: 'Get one point' }) + @ApiParam({ name: 'pointId' }) + async getPoint( + @Param('pointId') pointId: string, + @Query() query: GetPointQueryDto, + ) { + return this.aiV2.ok(await this.client.getPoint(pointId, query.with_vectors)); + } + + @Delete('points/:pointId') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ summary: 'Delete a point' }) + @ApiParam({ name: 'pointId' }) + async deletePoint(@Param('pointId') pointId: string) { + return this.aiV2.ok(await this.client.deletePoint(pointId)); + } + + @Put('points/:pointId') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ summary: 'Replace point content (version-guarded)' }) + @ApiParam({ name: 'pointId' }) + @ApiBody({ type: ReplacePointDto }) + async replacePoint( + @Param('pointId') pointId: string, + @Body() body: ReplacePointDto, + ) { + return this.aiV2.ok(await this.client.replacePoint(pointId, body)); + } + + @Patch('points/:pointId/payload') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ summary: 'Patch point payload (version-guarded)' }) + @ApiParam({ name: 'pointId' }) + @ApiBody({ type: PatchPointPayloadDto }) + async patchPointPayload( + @Param('pointId') pointId: string, + @Body() body: PatchPointPayloadDto, + ) { + return this.aiV2.ok(await this.client.patchPointPayload(pointId, body)); + } + + @Post('retrieval/query') + @ApiOperation({ summary: 'Hybrid semantic retrieval (not the ask API)' }) + @ApiBody({ type: RetrievalQueryDto }) + async queryRetrieval(@Body() body: RetrievalQueryDto) { + return this.aiV2.ok(await this.client.queryRetrieval(body)); + } +} diff --git a/src/ai-v2/ai-v2-ask.mapper.spec.ts b/src/ai-v2/ai-v2-ask.mapper.spec.ts new file mode 100644 index 0000000..3bf9037 --- /dev/null +++ b/src/ai-v2/ai-v2-ask.mapper.spec.ts @@ -0,0 +1,48 @@ +import { Sender } from '../common/types/sender.type'; +import { + isEscalationOffered, + resolveAskText, + resolveSessionId, + toAskCompatiblePayload, +} from './ai-v2-ask.mapper'; + +describe('ai-v2 ask mapper', () => { + it('reads question only', () => { + expect(resolveAskText({ question: 'الف' })).toBe('الف'); + expect(resolveAskText({ question: ' ' })).toBe(''); + }); + + it('treats empty sessionId as a new session', () => { + expect(resolveSessionId('')).toBeUndefined(); + expect(resolveSessionId(' ')).toBeUndefined(); + expect(resolveSessionId('64f1a2b3c4d5e6f7a8b9c0d1')).toBe( + '64f1a2b3c4d5e6f7a8b9c0d1', + ); + }); + + it('detects escalation_offered', () => { + expect(isEscalationOffered({ status: 'escalation_offered' })).toBe(true); + expect(isEscalationOffered({ status: 'answered' })).toBe(false); + }); + + it('keeps our messageId and stores AI runId separately', () => { + const payload = toAskCompatiblePayload({ + session: { + _id: '64f1a2b3c4d5e6f7a8b9c0d1', + messages: [{ sender: Sender.User }, { sender: Sender.Bot }], + }, + now: Date.now() / 1000, + question: 'چقدر پوشش بستری؟', + answer: 'با کارشناس تماس بگیرید', + messageId: '64f1a2b3c4d5e6f7a8b9c0d2', + runId: 'bea53a8d-3745-420f-a50d-fb700de5611d', + status: 'escalation_offered', + escalation: { summary: 'not in docs', handoff_context: {} }, + isNewSession: true, + }); + + expect(payload.messageId).toBe('64f1a2b3c4d5e6f7a8b9c0d2'); + expect(payload.runId).toBe('bea53a8d-3745-420f-a50d-fb700de5611d'); + expect(payload.offerOnlineChat).toBe(true); + }); +}); diff --git a/src/ai-v2/ai-v2-ask.mapper.ts b/src/ai-v2/ai-v2-ask.mapper.ts new file mode 100644 index 0000000..b38acd3 --- /dev/null +++ b/src/ai-v2/ai-v2-ask.mapper.ts @@ -0,0 +1,118 @@ +import { HttpStatus } from '@nestjs/common'; +import { Types } from 'mongoose'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { Sender } from 'src/common/types/sender.type'; +import { AiV2RunResult } from './ai-v2.client'; + +export const AI_UNAVAILABLE_FALLBACK = + 'به دلیل اختلال در سیستم زیر ساخت های کشور سرویس هوش مصنوعی در دسترس نیست. لطفا جهت دریافت راهنمایی به کارشناس متصل شوید. '; + +export type AskCompatiblePayload = { + sessionId: string; + count: string; + date?: string; + time?: string; + createdAt?: [string, string]; + question: string; + answer: string; + history: unknown[]; + messageId: string; + runId: string | null; + status: 'answered' | 'escalation_offered' | 'ai_unavailable'; + offerOnlineChat: boolean; + escalation: AiV2RunResult['escalation']; +}; + +export function resolveAskText(body: { question?: string }): string { + return String(body.question || '').trim(); +} + +export function resolveSessionId(sessionId?: string): string | undefined { + const trimmed = String(sessionId || '').trim(); + return trimmed || undefined; +} + +export function isEscalationOffered(result: Pick): boolean { + return result.status === 'escalation_offered'; +} + +export function buildUserMessage(text: string, now: number) { + return { + messageId: new Types.ObjectId(), + text, + sender: Sender.User, + react: 'Nothing', + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + }; +} + +export function buildBotMessage(params: { + text: string; + now: number; + runId?: string | null; + status?: AskCompatiblePayload['status']; + escalation?: AiV2RunResult['escalation']; +}) { + return { + messageId: new Types.ObjectId(), + text: params.text, + sender: Sender.Bot, + react: null, + runId: params.runId || undefined, + createdAt: TimeHelper.unix2PersianTimeAndDate(params.now), + createdISO: Date.now(), + aiStatus: params.status, + escalation: params.escalation || undefined, + }; +} + +export function toAskCompatiblePayload(params: { + session: { + _id: unknown; + messages: Array<{ sender: string; messageId?: unknown; runId?: string }>; + createdAt?: [string, string]; + }; + now: number; + question: string; + answer: string; + messageId: string; + runId?: string | null; + status: AskCompatiblePayload['status']; + escalation?: AiV2RunResult['escalation']; + isNewSession: boolean; +}): AskCompatiblePayload { + const botCount = params.session.messages.filter( + (message) => message.sender === Sender.Bot, + ).length; + const persian = TimeHelper.unix2PersianTimeAndDate(params.now); + const offerOnlineChat = + params.status === 'escalation_offered' || params.status === 'ai_unavailable'; + + const payload: AskCompatiblePayload = { + sessionId: String(params.session._id), + count: `${botCount}/20`, + question: params.question, + answer: params.answer, + history: params.session.messages, + messageId: params.messageId, + runId: params.runId ?? null, + status: params.status, + offerOnlineChat, + escalation: params.escalation ?? null, + }; + + if (params.isNewSession) { + payload.date = persian[1]; + payload.time = persian[0]; + } else { + payload.createdAt = persian as [string, string]; + } + + return payload; +} + +export function wrapAskResponse(data: AskCompatiblePayload) { + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); +} diff --git a/src/ai-v2/ai-v2-threads.controller.ts b/src/ai-v2/ai-v2-threads.controller.ts new file mode 100644 index 0000000..fe522d4 --- /dev/null +++ b/src/ai-v2/ai-v2-threads.controller.ts @@ -0,0 +1,71 @@ +import { Body, Controller, Post, Req, Res } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiProduces, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { CurrentIdentity } from 'src/common/decorators/Identity.decorator'; +import { AiV2Service } from './ai-v2.service'; +import { CreateFeedbackDto, CreateRunDto } from './dto/threads.dto'; + +@ApiTags('v2 AI') +@ApiBearerAuth() +@Controller('ai/v2/threads') +export class AiV2ThreadsController { + constructor(private readonly aiV2: AiV2Service) {} + + @Post('runs') + @ApiOperation({ + summary: 'Ask the new AI (same shape as /user/ask)', + description: + 'Body: `{ question, sessionId? }`. Empty/omitted sessionId starts a new session. ' + + 'We generate our own messageId and store the AI run_id on the bot message. ' + + 'When AI_V2_STREAM=true, emits SSE `meta` → `token*` → `result`.', + }) + @ApiBody({ type: CreateRunDto }) + @ApiProduces('application/json', 'text/event-stream') + @ApiResponse({ + status: 200, + description: 'Ask-compatible JSON, or SSE when AI_V2_STREAM=true.', + }) + async createRun( + @Body() body: CreateRunDto, + @CurrentIdentity() user: any, + @Req() req: Request, + @Res() res: Response, + ) { + try { + await this.aiV2.run(body, user, req, res); + } catch (err) { + if (res.headersSent) { + const message = err?.message || 'ai_v2_run_failed'; + const code = err?.getStatus?.() || 502; + res.write( + `event: error\ndata: ${JSON.stringify({ code, message })}\n\n`, + ); + res.end(); + return; + } + throw err; + } + } + + @Post('feedback') + @ApiOperation({ + summary: 'Thumbs up/down for a bot answer', + description: + '`helpful: true` stores Like, `false` stores Dislike on the message. ' + + 'The same boolean is posted to the AI as run feedback using the stored `runId`.', + }) + @ApiBody({ type: CreateFeedbackDto }) + async createFeedback( + @Body() body: CreateFeedbackDto, + @CurrentIdentity() user: any, + ) { + return this.aiV2.feedback(body, user); + } +} diff --git a/src/ai-v2/ai-v2.client.ts b/src/ai-v2/ai-v2.client.ts new file mode 100644 index 0000000..ec8d10a --- /dev/null +++ b/src/ai-v2/ai-v2.client.ts @@ -0,0 +1,407 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; +import * as FormData from 'form-data'; +import { Readable } from 'node:stream'; +import { AiV2Exception } from './ai-v2.exception'; +import { iterateSseEvents, readStreamToString } from './sse'; + +export type AiV2RunResult = { + run_id: string; + status: 'answered' | 'escalation_offered'; + message: string; + escalation: { summary?: string; handoff_context?: Record } | null; +}; + +@Injectable() +export class AiV2Client { + private baseUrl(): string { + const url = process.env.AI_V2_BASE_URL?.trim(); + if (!url) { + throw new AiV2Exception( + HttpStatus.SERVICE_UNAVAILABLE, + 'ai_v2_base_url_missing', + ); + } + return url.replace(/\/$/, ''); + } + + private apiKey(): string { + const key = process.env.AI_V2_API_KEY?.trim(); + if (!key) { + throw new AiV2Exception( + HttpStatus.SERVICE_UNAVAILABLE, + 'ai_v2_api_key_missing', + ); + } + return key; + } + + timeoutMs(): number { + const raw = process.env.AI_V2_TIMEOUT || process.env.AI_SERVICE_TIMEOUT; + const parsed = parseInt(String(raw || '75000'), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 75_000; + } + + isStreamToFrontendEnabled(): boolean { + const raw = process.env.AI_V2_STREAM?.trim().toLowerCase(); + return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; + } + + private headers(extra: Record = {}): Record { + return { + Authorization: `Bearer ${this.apiKey()}`, + accept: 'application/json', + ...extra, + }; + } + + private qs(params?: object): Record { + const out: Record = {}; + if (!params) return out; + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue; + out[key] = value; + } + return out; + } + + private throwFromAxios(err: unknown): never { + if (err instanceof AiV2Exception) throw err; + const axiosErr = err as AxiosError; + if (axiosErr?.isAxiosError) { + const status = + axiosErr.response?.status || HttpStatus.BAD_GATEWAY; + const data = axiosErr.response?.data ?? axiosErr.message; + const message = + (data as any)?.error?.message || + (data as any)?.message || + 'ai_v2_request_failed'; + throw new AiV2Exception(status, message, data); + } + throw new AiV2Exception( + HttpStatus.BAD_GATEWAY, + (err as Error)?.message || 'ai_v2_request_failed', + ); + } + + private async request(config: AxiosRequestConfig): Promise { + try { + const response = await axios.request({ + timeout: this.timeoutMs(), + maxBodyLength: Infinity, + ...config, + baseURL: this.baseUrl(), + headers: this.headers(config.headers as Record), + }); + return response.data; + } catch (err) { + this.throwFromAxios(err); + } + } + + listDomains(includeDisabled?: boolean) { + return this.request({ + method: 'GET', + url: '/api/v1/domains', + params: this.qs({ include_disabled: includeDisabled }), + }); + } + + createDomain(body: object) { + return this.request({ + method: 'POST', + url: '/api/v1/domains', + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + } + + updateDomain(domain: string, body: object) { + return this.request({ + method: 'PATCH', + url: `/api/v1/domains/${encodeURIComponent(domain)}`, + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + } + + disableDomain(domain: string) { + return this.request({ + method: 'DELETE', + url: `/api/v1/domains/${encodeURIComponent(domain)}`, + }); + } + + enableDomain(domain: string) { + return this.request({ + method: 'POST', + url: `/api/v1/domains/${encodeURIComponent(domain)}/enable`, + }); + } + + listDomainPoints(domain: string, query: object) { + return this.request({ + method: 'GET', + url: `/api/v1/domains/${encodeURIComponent(domain)}/points`, + params: this.qs(query), + }); + } + + async uploadFile(file: Express.Multer.File, domain: string) { + const form = new FormData(); + form.append('file', file.buffer, { + filename: file.originalname, + contentType: file.mimetype || 'application/octet-stream', + }); + form.append('domain', domain); + return this.request({ + method: 'POST', + url: '/api/v1/files', + data: form, + headers: form.getHeaders() as Record, + timeout: Math.max(this.timeoutMs(), 120_000), + }); + } + + listFiles(query: object) { + return this.request({ + method: 'GET', + url: '/api/v1/files', + params: this.qs(query), + }); + } + + getFile(fileId: string) { + return this.request({ + method: 'GET', + url: `/api/v1/files/${encodeURIComponent(fileId)}`, + }); + } + + deleteFile(fileId: string) { + return this.request({ + method: 'DELETE', + url: `/api/v1/files/${encodeURIComponent(fileId)}`, + }); + } + + listFilePoints(fileId: string, query: object) { + return this.request({ + method: 'GET', + url: `/api/v1/files/${encodeURIComponent(fileId)}/points`, + params: this.qs(query), + }); + } + + countPoints(query: object) { + return this.request({ + method: 'GET', + url: '/api/v1/points/count', + params: this.qs(query), + }); + } + + searchPoints(query: object) { + return this.request({ + method: 'GET', + url: '/api/v1/points/search', + params: this.qs(query), + }); + } + + listPoints(query: object) { + return this.request({ + method: 'GET', + url: '/api/v1/points', + params: this.qs(query), + }); + } + + createPoint(body: object) { + return this.request({ + method: 'POST', + url: '/api/v1/points', + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + } + + getPoint(pointId: string, withVectors?: boolean) { + return this.request({ + method: 'GET', + url: `/api/v1/points/${encodeURIComponent(pointId)}`, + params: this.qs({ with_vectors: withVectors }), + }); + } + + deletePoint(pointId: string) { + return this.request({ + method: 'DELETE', + url: `/api/v1/points/${encodeURIComponent(pointId)}`, + }); + } + + replacePoint(pointId: string, body: object) { + return this.request({ + method: 'PUT', + url: `/api/v1/points/${encodeURIComponent(pointId)}`, + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + } + + patchPointPayload(pointId: string, body: object) { + return this.request({ + method: 'PATCH', + url: `/api/v1/points/${encodeURIComponent(pointId)}/payload`, + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + } + + queryRetrieval(body: object) { + return this.request({ + method: 'POST', + url: '/api/v1/retrieval/query', + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + } + + createFeedback( + threadId: string, + runId: string, + body: object, + ) { + return this.request({ + method: 'POST', + url: `/api/v1/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/feedback`, + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + } + + private async openRunStream( + threadId: string, + body: { message: string; user_id: string }, + ): Promise> { + try { + const response = await axios.request({ + method: 'POST', + baseURL: this.baseUrl(), + url: `/api/v1/threads/${encodeURIComponent(threadId)}/runs`, + data: body, + headers: this.headers({ + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }), + responseType: 'stream', + timeout: this.timeoutMs(), + validateStatus: () => true, + maxBodyLength: Infinity, + }); + return response; + } catch (err) { + this.throwFromAxios(err); + } + } + + private async rejectStreamResponse(response: AxiosResponse): Promise { + const raw = await readStreamToString(response.data); + let data: unknown = raw; + try { + data = raw ? JSON.parse(raw) : raw; + } catch { + data = raw; + } + const message = + (data as any)?.error?.message || + (data as any)?.message || + 'ai_v2_run_failed'; + throw new AiV2Exception(response.status, message, data); + } + + async consumeRun( + threadId: string, + body: { message: string; user_id: string }, + handlers?: { + onToken?: (text: string) => void; + onClientClose?: (abort: () => void) => void; + }, + ): Promise { + const response = await this.openRunStream(threadId, body); + if (response.status >= 400) { + await this.rejectStreamResponse(response); + } + + const upstream = response.data; + const abort = () => { + if (typeof (upstream as any).destroy === 'function') { + (upstream as any).destroy(); + } + }; + handlers?.onClientClose?.(abort); + + let result: AiV2RunResult | null = null; + for await (const event of iterateSseEvents(upstream)) { + if (event.event === 'token') { + const text = (event.data as any)?.text; + if (typeof text === 'string' && text.length) { + handlers?.onToken?.(text); + } + continue; + } + if (event.event === 'error') { + const message = + (event.data as any)?.message || 'ai_v2_run_failed'; + throw new AiV2Exception(HttpStatus.BAD_GATEWAY, message, event.data); + } + if (event.event === 'result' && event.data && typeof event.data === 'object') { + result = event.data as AiV2RunResult; + } + } + + if (!result) { + throw new AiV2Exception( + HttpStatus.BAD_GATEWAY, + 'ai_v2_run_missing_result', + ); + } + return result; + } + + async collectRun( + threadId: string, + body: { message: string; user_id: string }, + ): Promise { + return this.consumeRun(threadId, body); + } + + async pipeRun( + threadId: string, + body: { message: string; user_id: string }, + dest: NodeJS.WritableStream, + onClientClose?: (abort: () => void) => void, + ): Promise { + const response = await this.openRunStream(threadId, body); + if (response.status >= 400) { + await this.rejectStreamResponse(response); + } + + const upstream = response.data; + const abort = () => { + if (typeof (upstream as any).destroy === 'function') { + (upstream as any).destroy(); + } + }; + onClientClose?.(abort); + + await new Promise((resolve, reject) => { + upstream.on('error', reject); + dest.on('error', reject); + dest.on('close', abort); + upstream.on('end', () => resolve()); + upstream.pipe(dest, { end: true }); + }); + } +} diff --git a/src/ai-v2/ai-v2.exception.ts b/src/ai-v2/ai-v2.exception.ts new file mode 100644 index 0000000..61f9383 --- /dev/null +++ b/src/ai-v2/ai-v2.exception.ts @@ -0,0 +1,11 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class AiV2Exception extends HttpException { + constructor( + status: number = HttpStatus.BAD_GATEWAY, + message = 'ai_v2_error', + data: unknown = null, + ) { + super({ statusCode: status, message, data }, status); + } +} diff --git a/src/ai-v2/ai-v2.module.ts b/src/ai-v2/ai-v2.module.ts new file mode 100644 index 0000000..146c377 --- /dev/null +++ b/src/ai-v2/ai-v2.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { AiV2AdminController } from './ai-v2-admin.controller'; +import { AiV2ThreadsController } from './ai-v2-threads.controller'; +import { AiV2Client } from './ai-v2.client'; +import { AiV2Service } from './ai-v2.service'; + +@Module({ + imports: [DatabaseModule], + controllers: [AiV2AdminController, AiV2ThreadsController], + providers: [AiV2Client, AiV2Service], + exports: [AiV2Client, AiV2Service], +}) +export class AiV2Module {} diff --git a/src/ai-v2/ai-v2.service.ts b/src/ai-v2/ai-v2.service.ts new file mode 100644 index 0000000..178770a --- /dev/null +++ b/src/ai-v2/ai-v2.service.ts @@ -0,0 +1,326 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Request, Response } from 'express'; +import { Model, Types } from 'mongoose'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { ReactEnum } from 'src/common/types/react.type'; +import { Sender } from 'src/common/types/sender.type'; +import { ReactsModel } from 'src/database/model/botReact.model'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { + AI_UNAVAILABLE_FALLBACK, + AskCompatiblePayload, + buildBotMessage, + buildUserMessage, + isEscalationOffered, + resolveAskText, + resolveSessionId, + toAskCompatiblePayload, + wrapAskResponse, +} from './ai-v2-ask.mapper'; +import { AiV2Client, AiV2RunResult } from './ai-v2.client'; +import { AiV2Exception } from './ai-v2.exception'; +import { CreateFeedbackDto, CreateRunDto } from './dto/threads.dto'; + +@Injectable() +export class AiV2Service { + constructor( + private readonly client: AiV2Client, + @InjectModel(SessionModel.name) + private readonly session: Model, + @InjectModel(ReactsModel.name) + private readonly reacts: Model, + ) {} + + identityUserId(user: any): string { + const id = user?._id ?? user?.userData?._id ?? user?.id; + if (!id) { + throw new AiV2Exception(HttpStatus.UNAUTHORIZED, 'user_id_required'); + } + return String(id); + } + + private userObjectId(user: any): Types.ObjectId { + return new Types.ObjectId(this.identityUserId(user)); + } + + async run(body: CreateRunDto, user: any, req: Request, res: Response) { + console.log('run service called with body:', body); + const question = resolveAskText(body); + if (!question) { + throw new AiV2Exception(HttpStatus.BAD_REQUEST, 'question_required'); + } + + const now = Date.now() / 1000; + const sessionId = resolveSessionId(body.sessionId); + const isNewSession = !sessionId; + const session = isNewSession + ? await this.createSession(question, user, now) + : await this.appendUserTurn(sessionId, question, user, now); + + const stream = this.client.isStreamToFrontendEnabled(); + if (stream) { + this.beginSse(res); + this.writeSse(res, 'meta', { + sessionId: String(session._id), + isNewSession, + }); + } + + let result: AiV2RunResult | null = null; + let status: AskCompatiblePayload['status'] = 'answered'; + let answer = AI_UNAVAILABLE_FALLBACK; + let runId: string | null = null; + + try { + result = await this.client.consumeRun( + String(session._id), + { message: question, user_id: this.identityUserId(user) }, + { + onToken: stream + ? (text) => this.writeSse(res, 'token', { text }) + : undefined, + onClientClose: (abort) => { + res.on('close', () => { + if (!res.writableEnded) abort(); + }); + }, + }, + ); + answer = result.message; + status = isEscalationOffered(result) + ? 'escalation_offered' + : 'answered'; + runId = result.run_id; + } catch (err) { + if ( + err instanceof HttpException && + err.getStatus() < 500 && + err.getStatus() !== HttpStatus.BAD_GATEWAY + ) { + throw err; + } + status = 'ai_unavailable'; + } + + const sessionObjectId = new Types.ObjectId(String(session._id)); + const botMessage = buildBotMessage({ + text: answer, + now, + runId, + status, + escalation: result?.escalation, + }); + await this.session.updateOne( + { _id: sessionObjectId }, + { $push: { messages: botMessage } }, + ); + + const updated = await this.session.findById(sessionObjectId).exec(); + if (!updated) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + const botCount = updated.messages.filter( + (message) => message.sender === Sender.Bot, + ).length; + const sessionPatch: Record = {}; + if (botCount >= 20) sessionPatch.chatClosed = true; + if (status === 'escalation_offered') sessionPatch.escalationOffered = true; + if (Object.keys(sessionPatch).length) { + await this.session.updateOne( + { _id: sessionObjectId }, + { $set: sessionPatch }, + ); + if (sessionPatch.chatClosed) (updated as any).chatClosed = true; + if (sessionPatch.escalationOffered) { + (updated as any).escalationOffered = true; + } + } + + const payload = toAskCompatiblePayload({ + session: updated, + now, + question, + answer, + messageId: String(botMessage.messageId), + runId, + status, + escalation: result?.escalation, + isNewSession, + }); + + if (stream) { + this.writeSse(res, 'result', payload); + res.end(); + return; + } + + res.status(HttpStatus.OK).json(wrapAskResponse(payload)); + } + + async feedback(body: CreateFeedbackDto, user: any) { + const sessionId = resolveSessionId(body.sessionId); + if (!sessionId || !Types.ObjectId.isValid(sessionId)) { + throw new AiV2Exception(HttpStatus.BAD_REQUEST, 'invalid_session_id'); + } + if (!Types.ObjectId.isValid(body.messageId)) { + throw new AiV2Exception(HttpStatus.BAD_REQUEST, 'invalid_message_id'); + } + + const sessionObjectId = new Types.ObjectId(sessionId); + const messageObjectId = new Types.ObjectId(body.messageId); + const userObjectId = this.userObjectId(user); + const react = body.helpful ? ReactEnum.like : ReactEnum.dislike; + + const session = await this.session + .findOne({ + _id: sessionObjectId, + userId: userObjectId, + }) + .exec(); + if (!session) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + const botMessageIndex = session.messages.findIndex( + (message) => String(message.messageId) === String(body.messageId), + ); + if (botMessageIndex < 0) { + throw new HttpException('message_not_found', HttpStatus.NOT_FOUND); + } + + const botMessage = session.messages[botMessageIndex]; + if (botMessage.runId) { + await this.client.createFeedback(String(session._id), botMessage.runId, { + helpful: body.helpful, + user_id: this.identityUserId(user), + comment: body.comment ?? null, + }); + } + + await this.session.updateOne( + { + _id: sessionObjectId, + 'messages.messageId': messageObjectId, + }, + { + $set: { 'messages.$.react': react }, + }, + ); + + let questionText: string | null = null; + for (let i = botMessageIndex - 1; i >= 0; i--) { + if (session.messages[i].sender === Sender.User) { + questionText = session.messages[i].text; + break; + } + } + + await this.reacts.findOneAndUpdate( + { + sessionId: sessionObjectId, + messageId: messageObjectId, + userId: userObjectId, + }, + { + $set: { + question: questionText, + answer: botMessage.text, + sender: botMessage.sender, + react, + createdAt: TimeHelper.unix2PersianTimeAndDate(Date.now() / 1000), + createdISO: new Date(), + }, + }, + { upsert: true, new: true }, + ); + + const updated = await this.session.findById(sessionObjectId).exec(); + if (!updated) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + return this.ok({ + sessionId: updated._id, + messageId: String(body.messageId), + runId: botMessage.runId || null, + helpful: body.helpful, + react, + messages: updated.messages, + }); + } + + ok(data: T, status: HttpStatus = HttpStatus.OK, message = 'SUCCESS') { + return new BaseResponseDTO(status, message, data); + } + + created(data: T) { + return new BaseResponseDTO(HttpStatus.CREATED, 'SUCCESS', data); + } + + private async createSession(question: string, user: any, now: number) { + return this.session.create({ + _id: new Types.ObjectId(), + userId: this.userObjectId(user), + chatTitle: question, + connectedToExpert: false, + onlineChatClosed: false, + chatClosed: false, + escalationOffered: false, + expert: '', + expertRate: null, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + messages: [buildUserMessage(question, now)], + }); + } + + private async appendUserTurn( + sessionId: string, + question: string, + user: any, + now: number, + ) { + if (!Types.ObjectId.isValid(sessionId)) { + throw new AiV2Exception(HttpStatus.BAD_REQUEST, 'invalid_session_id'); + } + const session = await this.session + .findOne({ + _id: new Types.ObjectId(sessionId), + userId: this.userObjectId(user), + }) + .exec(); + + if (!session) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + if (session.chatClosed) { + throw new HttpException('session_limit_reached', HttpStatus.BAD_REQUEST); + } + + await this.session.updateOne( + { _id: session._id }, + { $push: { messages: buildUserMessage(question, now) } }, + ); + return session; + } + + private beginSse(res: Response) { + res.status(200); + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + if (typeof (res as any).flushHeaders === 'function') { + (res as any).flushHeaders(); + } + res.socket?.setNoDelay?.(true); + } + + private writeSse(res: Response, event: string, data: unknown) { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + if (typeof (res as any).flush === 'function') { + (res as any).flush(); + } + } +} diff --git a/src/ai-v2/dto/common-query.dto.ts b/src/ai-v2/dto/common-query.dto.ts new file mode 100644 index 0000000..6e60fb0 --- /dev/null +++ b/src/ai-v2/dto/common-query.dto.ts @@ -0,0 +1,30 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsInt, IsOptional, IsString, Min } from 'class-validator'; + +export function toOptionalBoolean(value: unknown): boolean | undefined { + if (value === undefined || value === null || value === '') return undefined; + if (value === true || value === 'true' || value === '1') return true; + if (value === false || value === 'false' || value === '0') return false; + return undefined; +} + +export class CursorPageQueryDto { + @ApiPropertyOptional({ default: 50 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + cursor?: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + include_inactive?: boolean; +} diff --git a/src/ai-v2/dto/domains.dto.ts b/src/ai-v2/dto/domains.dto.ts new file mode 100644 index 0000000..56eb87f --- /dev/null +++ b/src/ai-v2/dto/domains.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsBoolean, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; +import { toOptionalBoolean } from './common-query.dto'; + +export class ListDomainsQueryDto { + @ApiPropertyOptional({ default: false }) + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + include_disabled?: boolean; +} + +export class CreateDomainDto { + @ApiProperty({ + example: 'faq', + description: 'Immutable domain key (like a collection name).', + }) + @IsString() + @MinLength(1) + @MaxLength(80) + @Matches(/^[a-z0-9][a-z0-9_-]*$/) + domain: string; + + @ApiProperty({ example: 'FAQ' }) + @IsString() + @MinLength(1) + @MaxLength(200) + display_name: string; + + @ApiPropertyOptional({ type: 'object', additionalProperties: true }) + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdateDomainDto { + @ApiProperty({ example: 'Frequently asked questions' }) + @IsString() + @IsNotEmpty() + @MinLength(1) + @MaxLength(200) + display_name: string; +} diff --git a/src/ai-v2/dto/files.dto.ts b/src/ai-v2/dto/files.dto.ts new file mode 100644 index 0000000..41f3983 --- /dev/null +++ b/src/ai-v2/dto/files.dto.ts @@ -0,0 +1,17 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { CursorPageQueryDto, toOptionalBoolean } from './common-query.dto'; + +export class ListFilesQueryDto extends CursorPageQueryDto { + @ApiPropertyOptional({ example: 'faq' }) + @IsOptional() + @IsString() + domain?: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + include_deleted?: boolean; +} diff --git a/src/ai-v2/dto/points.dto.ts b/src/ai-v2/dto/points.dto.ts new file mode 100644 index 0000000..a07df3b --- /dev/null +++ b/src/ai-v2/dto/points.dto.ts @@ -0,0 +1,116 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsBoolean, + IsInt, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + Min, + MinLength, +} from 'class-validator'; +import { CursorPageQueryDto, toOptionalBoolean } from './common-query.dto'; + +export class ListPointsQueryDto extends CursorPageQueryDto { + @ApiProperty({ example: '0e8fdea8-d778-4e0d-9fc5-82326a840657' }) + @IsString() + @IsNotEmpty() + file_id: string; +} + +export class CountPointsQueryDto { + @ApiPropertyOptional({ example: 'faq' }) + @IsOptional() + @IsString() + domain?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + file_id?: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + include_inactive?: boolean; +} + +export class SearchPointsQueryDto extends CursorPageQueryDto { + @ApiProperty({ example: 'بیمه تکمیلی' }) + @IsString() + @IsNotEmpty() + q: string; + + @ApiPropertyOptional({ example: 'faq' }) + @IsOptional() + @IsString() + domain?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + file_id?: string; +} + +export class GetPointQueryDto { + @ApiPropertyOptional({ default: false }) + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + with_vectors?: boolean; +} + +export class CreatePointDto { + @ApiProperty({ format: 'uuid' }) + @IsString() + @IsNotEmpty() + file_id: string; + + @ApiProperty() + @IsString() + @MinLength(1) + content: string; + + @ApiPropertyOptional({ default: 'paragraph' }) + @IsOptional() + @IsString() + content_type?: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: 'Place the new point after this one. Omit to insert at start.', + }) + @IsOptional() + @IsString() + after_point_id?: string; +} + +export class ReplacePointDto { + @ApiProperty() + @IsString() + @MinLength(1) + content: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + content_type?: string; + + @ApiProperty({ description: 'Expected current version (optimistic lock).' }) + @IsInt() + @Min(1) + version: number; +} + +export class PatchPointPayloadDto { + @ApiProperty({ type: 'object', additionalProperties: true }) + @IsObject() + payload: Record; + + @ApiProperty({ description: 'Expected current version (optimistic lock).' }) + @IsInt() + @Min(1) + version: number; +} diff --git a/src/ai-v2/dto/retrieval.dto.ts b/src/ai-v2/dto/retrieval.dto.ts new file mode 100644 index 0000000..3e38e48 --- /dev/null +++ b/src/ai-v2/dto/retrieval.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator'; + +export class RetrievalQueryDto { + @ApiProperty({ example: 'مدارک بستری' }) + @IsString() + @MinLength(1) + query: string; + + @ApiPropertyOptional({ example: 'faq' }) + @IsOptional() + @IsString() + domain?: string | null; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsString() + file_id?: string | null; + + @ApiPropertyOptional({ minimum: 1 }) + @IsOptional() + @IsInt() + @Min(1) + limit?: number | null; + + @ApiPropertyOptional({ minimum: 1 }) + @IsOptional() + @IsInt() + @Min(1) + prefetch_limit?: number | null; +} diff --git a/src/ai-v2/dto/threads.dto.ts b/src/ai-v2/dto/threads.dto.ts new file mode 100644 index 0000000..ec2b4d9 --- /dev/null +++ b/src/ai-v2/dto/threads.dto.ts @@ -0,0 +1,59 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateRunDto { + @ApiProperty({ + example: 'مدارک لازم برای بستری چیست؟', + description: 'User question. Same field as /user/ask.', + }) + @IsString() + @MinLength(1) + @MaxLength(4000) + question: string; + + @ApiPropertyOptional({ + description: + 'Existing chat session id. Omit or leave empty on the first question.', + example: '', + }) + @IsOptional() + @IsString() + sessionId?: string; +} + +export class CreateFeedbackDto { + @ApiProperty({ + example: '64f1a2b3c4d5e6f7a8b9c0d1', + description: 'Chat session id (also the AI thread id).', + }) + @IsString() + @MinLength(1) + sessionId: string; + + @ApiProperty({ + example: '64f1a2b3c4d5e6f7a8b9c0d2', + description: 'Our bot messageId. Backend looks up the stored AI runId.', + }) + @IsString() + @MinLength(1) + messageId: string; + + @ApiProperty({ + example: true, + description: 'true = Like, false = Dislike. Also sent to the AI as `helpful`.', + }) + @IsBoolean() + helpful: boolean; + + @ApiPropertyOptional({ maxLength: 2000 }) + @IsOptional() + @IsString() + @MaxLength(2000) + comment?: string | null; +} diff --git a/src/ai-v2/sse.spec.ts b/src/ai-v2/sse.spec.ts new file mode 100644 index 0000000..ce9f412 --- /dev/null +++ b/src/ai-v2/sse.spec.ts @@ -0,0 +1,27 @@ +import { Readable } from 'node:stream'; +import { collectSseEvents, parseSseBlock } from './sse'; + +describe('ai-v2 sse', () => { + it('parses token and result blocks', () => { + const token = parseSseBlock('event: token\ndata: {"text":"سلام"}'); + expect(token).toEqual({ + event: 'token', + data: { text: 'سلام' }, + rawData: '{"text":"سلام"}', + }); + + const result = parseSseBlock( + 'event: result\ndata: {"run_id":"abc","status":"answered","message":"hi","escalation":null}', + ); + expect(result?.event).toBe('result'); + expect((result?.data as any).message).toBe('hi'); + }); + + it('collects a streamed run', async () => { + const payload = + 'event: token\ndata: {"text":"سلام"}\n\n' + + 'event: result\ndata: {"run_id":"1","status":"answered","message":"سلام","escalation":null}\n\n'; + const events = await collectSseEvents(Readable.from([payload])); + expect(events.map((e) => e.event)).toEqual(['token', 'result']); + }); +}); diff --git a/src/ai-v2/sse.ts b/src/ai-v2/sse.ts new file mode 100644 index 0000000..5495dee --- /dev/null +++ b/src/ai-v2/sse.ts @@ -0,0 +1,73 @@ +import { Readable } from 'node:stream'; + +export type SseEvent = { + event: string; + data: unknown; + rawData: string; +}; + +export function parseSseBlock(block: string): SseEvent | null { + const lines = block.split('\n'); + let event = 'message'; + const dataLines: string[] = []; + + for (const line of lines) { + if (!line || line.startsWith(':')) continue; + if (line.startsWith('event:')) { + event = line.slice(6).trim(); + continue; + } + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trimStart()); + } + } + + if (dataLines.length === 0) return null; + const rawData = dataLines.join('\n'); + let data: unknown = rawData; + try { + data = JSON.parse(rawData); + } catch { + data = rawData; + } + return { event, data, rawData }; +} + +export async function readStreamToString(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +} + +export async function* iterateSseEvents( + stream: Readable, +): AsyncGenerator { + let buffer = ''; + + for await (const chunk of stream) { + buffer += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); + const parts = buffer.split(/\r?\n\r?\n/); + buffer = parts.pop() ?? ''; + for (const part of parts) { + const parsed = parseSseBlock(part); + if (parsed) yield parsed; + } + } + + if (buffer.trim()) { + const parsed = parseSseBlock(buffer); + if (parsed) yield parsed; + } +} + +export async function collectSseEvents( + stream: Readable, +): Promise { + const events: SseEvent[] = []; + for await (const event of iterateSseEvents(stream)) { + events.push(event); + } + return events; +} diff --git a/src/api/admin/admin.controller.ts b/src/api/admin/admin.controller.ts new file mode 100644 index 0000000..f85add8 --- /dev/null +++ b/src/api/admin/admin.controller.ts @@ -0,0 +1,91 @@ +import { Controller, Get, Body, Patch, UseGuards, Param, Post, Req, HttpStatus } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { Role } from 'src/common/types/role.type'; +import { AdminModel } from 'src/database/model/admin.model'; +import { AclService } from 'src/acl/acl.service'; +import { EditProfileDto } from './dto/edit-profile.dto'; +import { AdminService } from './admin.service'; +import { createExpertDto } from './dto/create-new-expert.dto'; + +@Controller('admin') +@UseGuards(AdminGuard) +@ApiBearerAuth() +export class AdminController { + constructor( + private readonly adminService: AdminService, + private readonly aclService: AclService, + ) {} + + @Get('profile') + @Permissions(Permission.ProfileRead) + getProfile(@AdminIdentity() adminIdentity: AdminModel) { + return this.adminService.getProfile(adminIdentity); + } + + @Post('newExpert') + @Permissions(Permission.StaffCreateExpert) + async addNewExpert( + @Body() body: createExpertDto, + @AdminIdentity() adminIdentity: any, + @Req() req: any, + ) { + const created = await this.aclService.createStaff( + adminIdentity.userData._id, + { + email: body.email, + password: body.password, + mobile: body.mobile, + name: body.name, + family: body.family, + role: Role.Expert, + }, + req, + ); + return new BaseResponseDTO( + HttpStatus.CREATED, + 'Expert created successfully', + created, + ); + } + + @Patch('profile') + @Permissions(Permission.ProfileWrite) + editProfile( + @Body() body: EditProfileDto, + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.adminService.modifyProfile(body, adminIdentity); + } + + @Patch('experts/:expertId/toggle-active') + @Permissions(Permission.StaffCreateExpert) + async toggleExpertActivation( + @Param('expertId') expertId: string, + @AdminIdentity() adminIdentity: any, + @Req() req: any, + ) { + const current = await this.adminService.findOneAdmin({ _id: expertId }); + const nextActive = !(current?.isActive); + return this.aclService.setStaffActive( + adminIdentity.userData._id, + expertId, + Boolean(nextActive), + req, + ); + } + + @Get('dashboard') + @Permissions(Permission.DashboardView) + @ApiOperation({ + summary: 'Get admin dashboard statistics', + description: 'Returns real-time statistics for the admin dashboard including: experts in conversation, online experts, users chatting with expert, waiting users, total chatbot users, and insurance holder users. Data is fetched from Redis for real-time accuracy.', + }) + getDashboard() { + return this.adminService.getDashboard(); + } +} diff --git a/src/api/admin/admin.module.ts b/src/api/admin/admin.module.ts new file mode 100644 index 0000000..96b8ee4 --- /dev/null +++ b/src/api/admin/admin.module.ts @@ -0,0 +1,22 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { UploadModule } from 'src/upload/upload.module'; +import { SupportManagementModule } from 'src/socket/support-management/support-management.module'; +import { AclModule } from 'src/acl/acl.module'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; + +@Module({ + imports: [ + DatabaseModule, + UploadModule, + AclModule, + ...(process.env.SUPPORT_MANAGEMENT === 'true' + ? [forwardRef(() => SupportManagementModule)] + : []), + ], + controllers: [AdminController], + providers: [AdminService], + exports: [AdminService], +}) +export class AdminModule {} diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts new file mode 100644 index 0000000..faa7f3d --- /dev/null +++ b/src/api/admin/admin.service.ts @@ -0,0 +1,333 @@ +import { HttpException, HttpStatus, Injectable, Optional, Inject } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { FilterQuery, Model, Types } from 'mongoose'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { Role } from 'src/common/types/role.type'; +import { AdminDocument, AdminModel } from 'src/database/model/admin.model'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { UserModel } from 'src/database/model/user.model'; +import { ReassignedLogsModel } from 'src/database/model/reassigned-logs.model'; +import { ChatService } from 'src/socket/support-management/chat.service'; +import { RedisService } from 'src/common/helpers/redis.service'; +import { CreateAdminDto } from './dto/create-admin.dto'; +import { EditProfileDto } from './dto/edit-profile.dto'; +import { createExpertDto } from './dto/create-new-expert.dto'; +import * as crypto from 'node:crypto'; + +@Injectable() +export class AdminService { + constructor( + @InjectModel(AdminModel.name) + private readonly adminModel: Model, + @InjectModel(SessionModel.name) + private readonly sessionModel: Model, + @InjectModel(UserModel.name) + private readonly userModel: Model, + @InjectModel(ReassignedLogsModel.name) + private readonly reassignedLogsModel: Model, + @Optional() @Inject(ChatService) + private readonly chatService: ChatService | null, + private readonly redisService: RedisService, + ) {} + + async create(createAdminDto: CreateAdminDto, role: Role.Admin | Role.Expert | Role.Supervisor) { + return await this.adminModel.create({ + ...createAdminDto, + username: createAdminDto.email, + role: role, + }); + } + + async findOneAdmin(query: FilterQuery): Promise { + return await this.adminModel.findOne(query); + } + + async getProfile(adminIdentity): Promise { + try { + const admin = await this.adminModel + .findOne( + { + _id: new Types.ObjectId(adminIdentity.userData._id), + isActive: true, + }, + { password: 0, resetToken: 0 }, + ) + .populate('avatar'); + + if (!admin) throw new HttpException('not_found', HttpStatus.NOT_FOUND); + + // Use expert's email to find sessions (expert field stores email) + const expertEmail = admin.email; + + // Calculate total answered: sessions where expert actually connected (connectedToExpert === true) + // and the expert field matches this expert's email + const answeredSessions = await this.sessionModel.find({ + expert: expertEmail, + connectedToExpert: true, + onlineStartDate: { $exists: true }, + }); + + // Calculate activity time (sum of all answered session durations in seconds) + const activityTime = Math.round( + answeredSessions.reduce((total, session) => { + if (session.onlineStartDate && session.onlineEndDate) { + const duration = + (session.onlineEndDate.getTime() - + session.onlineStartDate.getTime()) / + 1000; + // Only add positive durations to avoid negative activity time + return total + (duration > 0 ? duration : 0); + } + return total; + }, 0), + ); + + // Calculate total answered (sessions where expert connected) + const totalAnswered = answeredSessions.length; + + // Calculate total not answered: count from reassigned_logs where this expert missed the chat + // (reassigned from them, meaning they were online but didn't answer) + const totalNotAnswered = await this.reassignedLogsModel.countDocuments({ + from: expertEmail, + }); + + // Calculate total sessions (answered + not answered) + const totalSessions = totalAnswered + totalNotAnswered; + + // Calculate total rate (average of all non-null rates from answered sessions) + const rates = answeredSessions + .filter( + (session) => + session.expertRate !== null && session.expertRate !== undefined, + ) + .map((session) => session.expertRate); + const totalRate = + rates.length > 0 + ? parseFloat((rates.reduce((sum, rate) => sum + rate, 0) / rates.length).toFixed(2)) + : 0; + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + _id: admin._id, + name: admin.name, + family: admin.family, + mobile: admin.mobile, + email: admin.email, + username: admin.username, + role: admin.role, + isActive: admin.isActive, + avatar: admin.avatar + ? `https://${process.env.BASEURL}/${(admin.avatar as any).filePath}` + : null, + birthDate: admin.birthDate, + reports: { + activityTime, + totalSessions, + totalAnswered, + totalNotAnswered, + totalRate, + currentNotActive: 0, // Will be implemented later + }, + }); + } catch (err) { + console.log(err); + throw new HttpException(err.message, HttpStatus.BAD_REQUEST); + } + } + + async createNewExpert(body:createExpertDto,adminIdentity:any){ + try { + const existingExpert = await this.adminModel.findOne({ // Explicitly type existingExpert as AdminModel | null + $or: [{ email: body.email }, { mobile: body.mobile }], + }); + + if (existingExpert) { + throw new HttpException('Expert with this email or mobile already exists', HttpStatus.BAD_REQUEST); + } + const hashedPassword = crypto.createHash('sha256').update(body.password).digest('hex'); + + const newExpert = await this.adminModel.create({ + email: body.email, + password: hashedPassword, + mobile: body.mobile, + name: body.name, + family: body.family, + role: 'expert', + username: body.email, // Set username to email + }); + + // Exclude password from the returned object + const { password, ...expertWithoutPassword } = newExpert.toObject(); + + return new BaseResponseDTO(HttpStatus.CREATED, 'Expert created successfully', expertWithoutPassword); + } catch (err) { + console.log(err); + throw new HttpException(err.message, HttpStatus.BAD_REQUEST); + } + } + + async modifyProfile(body: EditProfileDto, adminIdentity: any) { + const admin = await this.adminModel.findOne({ + _id: new Types.ObjectId(adminIdentity.userData._id), + isActive: true, + }); + + if (!admin) { + throw new HttpException('not_found', HttpStatus.NOT_FOUND); + } + + const updatePayload: any = { ...body }; + + const updatedUser = await this.adminModel + .findByIdAndUpdate(adminIdentity.userData._id, updatePayload, { + new: true, + projection: { + _id: 1, + role: 1, + mobile: 1, + email: 1, + username: 1, + name: 1, + family: 1, + createdAt: 1, + updatedAt: 1, + birthDate: 1, + birthday: 1, + avatar: 1, + }, + }) + .populate('avatar'); + + return updatedUser; + } + + async toggleExpertActivation(expertId: string): Promise { + try { + const expert = await this.adminModel.findOne({ + _id: new Types.ObjectId(expertId), + role: Role.Expert, + }); + + if (!expert) { + throw new HttpException('Expert not found', HttpStatus.NOT_FOUND); + } + + expert.isActive = !expert.isActive; + await expert.save(); + + const message = expert.isActive ? 'Expert activated successfully' : 'Expert deactivated successfully'; + const { password, ...expertWithoutPassword } = expert.toObject(); + return new BaseResponseDTO(HttpStatus.OK, message, expertWithoutPassword); + } catch (err) { + console.log(err); + throw new HttpException(err.message, HttpStatus.BAD_REQUEST); + } + } + + async getDashboard(): Promise { + try { + const redisClient = this.redisService.getClient(); + const ONLINE_EXPERTS_KEY = 'onlineExperts'; + const ROOMS_HASH_KEY = 'rooms:active'; + const WAITING_QUEUE_KEY = 'waitingQueue'; + const WAITING_USERS_KEY = 'waiting_users'; + + // Get all experts from Redis onlineExperts hash + let allExpertsFromRedis: any[] = []; + try { + const onlineExpertsEntries = await redisClient.hgetall(ONLINE_EXPERTS_KEY); + allExpertsFromRedis = Object.values(onlineExpertsEntries) + .map((v) => { + try { + return JSON.parse(v as string); + } catch { + return null; + } + }) + .filter((expert: any) => expert); + } catch (err) { + console.error('Error getting experts from Redis:', err); + } + + // 1. تعداد کارشناس در حال مکالمه - Number of experts currently in conversation + // Experts who have activeSessions > 0 + const expertsInConversation = allExpertsFromRedis.filter( + (expert: any) => expert.activeSessions > 0 + ); + const expertsInConversationCount = expertsInConversation.length; + + // 2. تعداد کارشناس آنلاین - Number of online experts + // Experts who are marked as online OR have active sessions (if in conversation, they should be online) + const onlineExpertIds = new Set(); + allExpertsFromRedis.forEach((expert: any) => { + if (expert.isOnline === true || expert.activeSessions > 0) { + onlineExpertIds.add(expert.expertId); + } + }); + const onlineExpertsCount = onlineExpertIds.size; + + // 3. تعداد کاربر در حال مکاتبه با کارشناس - Number of users currently chatting with expert + // Each room in rooms:active represents one active chat session + // Since each user can only have one active expert session, counting active rooms = counting unique users + let usersWithExpert = 0; + try { + if (this.chatService) { + const activeRooms = await this.chatService.getActiveRooms(); + usersWithExpert = activeRooms ? activeRooms.length : 0; + } else { + // Fallback: get directly from Redis if chatService is not available + const roomsEntries = await redisClient.hgetall(ROOMS_HASH_KEY); + const activeRooms = Object.values(roomsEntries) + .map((v) => { + try { + return JSON.parse(v as string); + } catch { + return null; + } + }) + .filter((room: any) => room && room.isActive); + + usersWithExpert = activeRooms.length; + } + } catch (err) { + console.error('Error getting users with expert from Redis:', err); + } + + // 4. تعداد کاربر در انتظار ارتباط با کارشناس - Number of users waiting to connect with expert + // Get from Redis waitingQueue list and waiting_users hash + let waitingUsersCount = 0; + try { + const waitingQueueLength = await redisClient.llen(WAITING_QUEUE_KEY); + const waitingUsersKeys = await redisClient.hkeys(WAITING_USERS_KEY); + waitingUsersCount = waitingQueueLength + waitingUsersKeys.length; + } catch (err) { + console.error('Error getting waiting users from Redis:', err); + } + + // 5. تعداد کاربر چت بات - Number of chatbot users (total unique users with sessions) + const totalChatbotUsers = await this.sessionModel.distinct('userId').then(ids => ids.length); + + // 6. تعداد کاربر بیمه گذار چت بات - Number of insurance holder chatbot users + // Users with nationalCode who have sessions + const usersWithSessions = await this.sessionModel.distinct('userId'); + const insuranceHolderUsers = await this.userModel.countDocuments({ + _id: { $in: usersWithSessions }, + nationalCode: { $exists: true, $nin: [null, ''] }, + }); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + expertsInConversation: expertsInConversationCount, + onlineExperts: onlineExpertsCount, + usersWithExpert: usersWithExpert, + waitingUsers: waitingUsersCount, + totalChatbotUsers: totalChatbotUsers, + insuranceHolderUsers: insuranceHolderUsers, + }); + } catch (err) { + console.error('Error in getDashboard:', err); + throw new HttpException( + err.message || 'Failed to fetch dashboard data', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/api/admin/dto/create-admin.dto.ts b/src/api/admin/dto/create-admin.dto.ts new file mode 100644 index 0000000..d49bee6 --- /dev/null +++ b/src/api/admin/dto/create-admin.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsStrongPassword } from 'class-validator'; + +export class CreateAdminDto { + @ApiProperty({ description: 'email please' }) + @IsEmail() + email: string; + + @ApiProperty() + @IsStrongPassword({ minLength: 5 }) + password: string; + + @ApiProperty() + mobile: string; +} diff --git a/src/api/admin/dto/create-new-expert.dto.ts b/src/api/admin/dto/create-new-expert.dto.ts new file mode 100644 index 0000000..1af048d --- /dev/null +++ b/src/api/admin/dto/create-new-expert.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsStrongPassword } from 'class-validator'; + +export class createExpertDto { + @ApiProperty({ + required: false, + type: 'string', + description: 'email of the newly added expert (uses this to login)', + example: 'ittalie@expert.com', + }) + @IsEmail() + email: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'a strong password expert uses to login', + example: '123321', + }) +// @IsStrongPassword({ minLength: 5 }) + password: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'mobile of the expert , will be used for notifications and etc', + example: '09912334899', + }) + mobile: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'name of the expert will be showed in profile and any other places in app', + example: 'کارشناس', + }) + name: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'family of the expert will be showed in profile and any other places in app', + example: 'چت بات', + }) + family : string; +} diff --git a/src/api/admin/dto/edit-profile.dto.ts b/src/api/admin/dto/edit-profile.dto.ts new file mode 100644 index 0000000..0e562e9 --- /dev/null +++ b/src/api/admin/dto/edit-profile.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsString } from 'class-validator'; + +export class EditProfileDto { + @ApiProperty({ description: 'First Name of user' }) + @IsOptional() + @IsString() + name?: string; + + @ApiProperty({ description: 'Family name of user' }) + @IsOptional() + @IsString() + family?: string; + + @ApiProperty({ description: 'Birth date of user', example: '1373/01/01' }) + @IsOptional() + birthDate?: string; +} diff --git a/src/api/admin/dto/update-admin.dto.ts b/src/api/admin/dto/update-admin.dto.ts new file mode 100644 index 0000000..74613de --- /dev/null +++ b/src/api/admin/dto/update-admin.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateAdminDto } from './create-admin.dto'; + +export class UpdateAdminDto extends PartialType(CreateAdminDto) {} diff --git a/src/api/user/dto/user.dto.ts b/src/api/user/dto/user.dto.ts new file mode 100644 index 0000000..d525c0a --- /dev/null +++ b/src/api/user/dto/user.dto.ts @@ -0,0 +1,76 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsEnum } from 'class-validator'; +import { ReactEnum } from 'src/common/types/react.type'; + +export class UserAskQuestion { + @ApiProperty({ + required: true, + type: 'string', + description: 'user asked question', + example: 'سلام چطوری وام بگیرم ؟', + }) + question: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'current sessionId , if its first question it can be null', + example: '', + }) + sessionId: string; +} + +export class UserReactToMessage { + @ApiProperty({ + required: true, + type: 'string', + description: 'user react to bot answer. like|dislike', + example: 'like', + }) + @Transform(({ value }) => { + if (typeof value !== 'string') return value; + const normalized = value.toLowerCase(); + if (normalized === 'like') return ReactEnum.like; + if (normalized === 'dislike') return ReactEnum.dislike; + if (normalized === 'nothing') return ReactEnum.nothing; + return value; + }) + @IsEnum(ReactEnum) + react: ReactEnum; + + @ApiProperty({ + required: true, + type: 'string', + description: 'current messageId , if its first question it can be null', + example: '', + }) + messageId: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'current sessionId , if its first question it can be null', + example: '', + }) + sessionId: string; +} + +export class UserRateTheExpert { + @ApiProperty({ + required: true, + type: 'string', + description: + 'the id of the session of the current chat. online chat session id equals to normal chatting to bot.', + example: '67a704301d45d2cafb4eec57', + }) + sessionId: string; + + @ApiProperty({ + required: true, + type: 'number', + description: 'user rates the expert , a number between 1 to 5', + example: '3', + }) + rate: number; +} diff --git a/src/api/user/user.controller.ts b/src/api/user/user.controller.ts new file mode 100644 index 0000000..7ec7d1c --- /dev/null +++ b/src/api/user/user.controller.ts @@ -0,0 +1,116 @@ +import { Controller, Get, Post, Body, Param, Put } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiDefaultResponse, + ApiOperation, + ApiParam, +} from '@nestjs/swagger'; +import { CurrentIdentity } from 'src/common/decorators/Identity.decorator'; +import { + UserAskQuestion, + UserRateTheExpert, + UserReactToMessage, +} from './dto/user.dto'; +import { UserService } from './user.service'; + +@Controller('user') +export class UserController { + constructor(private readonly userService: UserService) {} + + @ApiOperation({ + summary: 'Get User profile', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Get('profile') + async getProfile(@CurrentIdentity() user: any) { + return this.userService.getProfile(user); + } + + @ApiOperation({ + summary: 'Users ask their question using this api.', + }) + @ApiBody({ type: UserAskQuestion }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Post('ask') + async askQuestion( + @CurrentIdentity() user: any, + @Body() body: UserAskQuestion, + ) { + return this.userService.ask(body, user); + } + + @ApiOperation({ + summary: 'Get user sessions list including chat titles.', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Get('questions') + async userLastQuestionsHistory(@CurrentIdentity() user: any) { + return this.userService.getUserLastQuestionsHistory(user); + } + + @ApiOperation({ + summary: 'Get single session history details with session id.', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Get('questions/:sessionId') + @ApiParam({ name: 'sessionId' }) + async userSessionHistory( + @CurrentIdentity() user: any, + @Param('sessionId') sessionId?: string, + ) { + return this.userService.userSessionHistory(user, sessionId); + } + + @ApiOperation({ + summary: 'Get all users frequently asked questions', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Get('faq') + async usersFaq(@CurrentIdentity() user: any) { + return this.userService.getUsersFaq(user); + } + + @ApiOperation({ + summary: 'Get user insurance policies with installments', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Get('my-policies') + async getMyPolicies(@CurrentIdentity() user: any) { + return this.userService.getMyPolicies(user); + } + + @ApiOperation({ + summary: 'Users ask their question using this api.', + }) + @ApiBody({ type: UserReactToMessage }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Put('react') + async reactToMessage( + @CurrentIdentity() user: any, + @Body() body: UserReactToMessage, + ) { + return this.userService.react(body, user); + } + + @ApiOperation({ + summary: 'Users ask their question using this api.', + }) + @ApiBody({ type: UserRateTheExpert }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @Post('rate') + async rateTheExpert( + @CurrentIdentity() user: any, + @Body() body: UserRateTheExpert, + ) { + return this.userService.rateTheExpert(body, user); + } +} diff --git a/src/api/user/user.module.ts b/src/api/user/user.module.ts new file mode 100644 index 0000000..af10887 --- /dev/null +++ b/src/api/user/user.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AiServiceModule } from 'src/ai-service/ai-service.module'; +import { DatabaseModule } from 'src/database/database.module'; +import { PoliciesModule } from 'src/policies/policies.module'; +import { UserController } from './user.controller'; +import { UserService } from './user.service'; + +@Module({ + imports: [DatabaseModule, AiServiceModule, PoliciesModule], + controllers: [UserController], + providers: [UserService], + exports: [UserService], +}) +export class UserModule {} diff --git a/src/api/user/user.service.ts b/src/api/user/user.service.ts new file mode 100644 index 0000000..eb9a323 --- /dev/null +++ b/src/api/user/user.service.ts @@ -0,0 +1,935 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { FilterQuery, Model, Types, UpdateQuery } from 'mongoose'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { Sender } from 'src/common/types/sender.type'; +import { ReactEnum } from 'src/common/types/react.type'; +import { AdminModel } from 'src/database/model/admin.model'; +import { DictionariesModel } from 'src/database/model/dictionaries.model'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { UserModel } from 'src/database/model/user.model'; +import { ReactsModel } from 'src/database/model/botReact.model'; +import { AiServiceService } from '../../ai-service/ai-service.service'; +import { PoliciesService } from 'src/policies/policies.service'; +import { + UserAskQuestion, + UserRateTheExpert, + UserReactToMessage, +} from './dto/user.dto'; + +@Injectable() +export class UserService { + constructor( + @InjectModel(UserModel.name) private readonly user: Model, + @InjectModel(SessionModel.name) + private readonly session: Model, + @InjectModel(AdminModel.name) private readonly admin: Model, + @InjectModel(DictionariesModel.name) + private readonly dictionaries: Model, + @InjectModel(ReactsModel.name) + private readonly reacts: Model, + private readonly aiService: AiServiceService, + private readonly policiesService: PoliciesService, + ) {} + + private normalizeReactValue(value: string | ReactEnum): ReactEnum { + if (value === ReactEnum.like || value === ReactEnum.dislike || value === ReactEnum.nothing) { + return value as ReactEnum; + } + if (typeof value === 'string') { + const v = value.toLowerCase(); + if (v === 'like') return ReactEnum.like; + if (v === 'dislike') return ReactEnum.dislike; + if (v === 'nothing') return ReactEnum.nothing; + } + return ReactEnum.nothing; + } + + async getMyPolicies(user) { + try { + const userId = + typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; + const data = await this.policiesService.getUserPoliciesWithInstallments( + userId, + ); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } catch (err) { + console.log(err); + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'Failed to fetch policies', + null, + ); + } + } + + async getProfile(user) { + try { + const userId = + typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; + const userDoc = await this.user.findOne({ _id: userId }); + + if (!userDoc) + throw new HttpException('user_not_found', HttpStatus.NOT_FOUND); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + _id: userDoc._id, + name: userDoc.name || null, + family: userDoc.family || null, + birthDate: userDoc.birthDate || null, + nationalCode: userDoc.nationalCode || null, + mobile: userDoc.mobile || null, + email: userDoc.email || null, + address: userDoc.address || null, + username: userDoc.username || null, + }); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async getUserLastQuestionsHistory(user) { + try { + const now = Date.now() / 1000; + const userId = + typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; + const sessions = await this.session + .find({ userId: userId }) + .select('chatTitle _id createdAt createdISO') + .lean() + .exec(); + + // Calculate date boundaries + const nowMs = Date.now(); + const nowDate = new Date(nowMs); + + // 7 days ago + const sevenDaysAgo = nowMs - (7 * 24 * 60 * 60 * 1000); + + // 6 months ago (using actual month calculation) + const sixMonthsAgoDate = new Date(nowDate); + sixMonthsAgoDate.setMonth(sixMonthsAgoDate.getMonth() - 6); + const sixMonthsAgo = sixMonthsAgoDate.getTime(); + + // 12 months ago (using actual month calculation) + const twelveMonthsAgoDate = new Date(nowDate); + twelveMonthsAgoDate.setMonth(twelveMonthsAgoDate.getMonth() - 12); + const twelveMonthsAgo = twelveMonthsAgoDate.getTime(); + + // Initialize categorized arrays + const recent: any[] = []; // Last 7 days + const lastSixMonths: any[] = []; // Last 6 months (excluding last 7 days) + const previousSixMonths: any[] = []; // 6-12 months ago + + if (sessions && sessions.length > 0) { + for (let session of sessions) { + const sessionData = { + title: session["chatTitle"], + sessionId: session["_id"], + time: session["createdAt"][0], + date: session["createdAt"][1], + }; + + // Use createdISO for accurate date comparison + const sessionDate = session["createdISO"] + ? new Date(session["createdISO"]).getTime() + : null; + + if (sessionDate) { + if (sessionDate >= sevenDaysAgo) { + // Last 7 days (recent) + recent.push(sessionData); + } else if (sessionDate >= sixMonthsAgo) { + // Last 6 months (excluding last 7 days) + lastSixMonths.push(sessionData); + } else if (sessionDate >= twelveMonthsAgo) { + // Previous 6 months (6-12 months ago) + previousSixMonths.push(sessionData); + } + // Sessions older than 12 months are not included + } else { + // Fallback: if createdISO is missing, include in recent (shouldn't happen normally) + recent.push(sessionData); + } + } + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + recent, // Last 7 days + lastSixMonths, // Last 6 months (excluding last 7 days) + previousSixMonths, // 6-12 months ago + now: TimeHelper.unix2PersianTimeAndDate(now), + }); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async userSessionHistory(user, sessionId) { + try { + console.log('userSessionHistory called with sessionId:', sessionId); + if (!sessionId || !Types.ObjectId.isValid(sessionId)) { + console.error('Invalid sessionId provided to userSessionHistory:', sessionId); + throw new HttpException('invalid_session_id', HttpStatus.BAD_REQUEST); + } + const newSession = await this.session + .findOne({ + _id: new Types.ObjectId(sessionId), + // userId: user._id, + }) + .exec(); + + if (!newSession) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + sessionId: newSession._id, + chatTitle: newSession.chatTitle, + connectedToExpert: newSession.connectedToExpert, + onlineChatClosed: newSession.onlineChatClosed, + chatClosed: newSession.chatClosed, + count: `${newSession.messages.filter((message) => message.sender === Sender.Bot).length}/20`, + time: newSession.createdAt[0], + date: newSession.createdAt[1], + messages: newSession.messages, + }); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async rateTheExpert(body: UserRateTheExpert, user) { + try { + const now = Date.now() / 1000; + const userId = + typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; + // Fetch the session from the `sessions` collection + const sessionIdToFind = new Types.ObjectId(body.sessionId); + const newSession = await this.session + .findOne({ + _id: sessionIdToFind, + userId: userId, // Ensure the session belongs to the user + }) + .exec(); + + if (!newSession) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + if (!newSession.connectedToExpert) { + throw new HttpException('expert_not_connected', HttpStatus.BAD_REQUEST); + } + + // Check if the expert field is empty or null + if (!newSession.expert || newSession.expert.trim() === '') { + throw new HttpException('expert_not_assigned', HttpStatus.BAD_REQUEST); + } + + // Update the expert rate in the `sessions` collection + await this.session.updateOne( + { _id: sessionIdToFind }, + { + $set: { + expertRate: body.rate, + }, + }, + ); + + // Update the admin's rate in the `admin` collection + await this.admin.updateOne( + { username: newSession.expert }, + { + $push: { + rate: { + sessionId: body.sessionId, + rate: body.rate, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdAtDate: Date.now(), + }, + }, + }, + ); + + // Return success response + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', null); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async getUsersFaq(user) { + try { + const dictionaries = await this.dictionaries.find({ + isActive: true, + aiCollectionName: { $ne: 'agents' } + }); + + // Combine all questions from all dictionaries into a single array + const allQuestions = dictionaries.flatMap( + (dictionary) => dictionary.questions, + ); + + // Filter out questions where deleted is true + const filteredQuestions = allQuestions.filter( + (question) => question.deleted !== true, + ); + + // Extract only the `question` field from each question object + const questionsOnly = filteredQuestions.map( + (question) => question.question, + ); + + // Function to shuffle an array using Fisher-Yates algorithm + const shuffleArray = (array) => { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; + }; + + // Shuffle the questions and pick the first 3 + const shuffledQuestions = shuffleArray(questionsOnly); + const randomQuestions = shuffledQuestions.slice(0, 3); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', randomQuestions); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async react(body: UserReactToMessage, userData) { + try { + const { sessionId, messageId } = body; + const normalizedReact = this.normalizeReactValue((body as any).react); + const sessionObjectId = new Types.ObjectId(sessionId); + const userObjectId = new Types.ObjectId(userData._id); + const storedMessageId = this.toStoredMessageId(messageId); + + // Update the message's react field in the session + // Use updateOne first to ensure the update happens, then fetch the result + const updateResult = await this.session.updateOne( + { + _id: sessionObjectId, + 'messages.messageId': storedMessageId, + }, + { + $set: { + 'messages.$.react': normalizedReact, + }, + }, + ); + + if (updateResult.matchedCount === 0) { + throw new HttpException('message_not_found', HttpStatus.NOT_FOUND); + } + + // Fetch the updated session to get the reacted message + const result = await this.session.findById(sessionObjectId); + if (!result) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + const reactedMessage = result.messages.find( + (message) => String(message.messageId) === String(messageId), + ); + + // Verify the update actually happened + if (reactedMessage && reactedMessage.react !== normalizedReact) { + console.warn(`[react] Warning: React update may have failed. Expected: ${normalizedReact}, Got: ${reactedMessage.react}`); + } + + if (reactedMessage) { + const botMessageIndex = result.messages.findIndex( + (message) => String(message.messageId) === String(messageId), + ); + + let questionText = null; + if (botMessageIndex > 0) { + for (let i = botMessageIndex - 1; i >= 0; i--) { + if (result.messages[i].sender === Sender.User) { + questionText = result.messages[i].text; + break; + } + } + } + + // Update or create the react entry in the reacts collection + // This ensures we only have one react per user per message (the latest one) + await this.reacts.findOneAndUpdate( + { + sessionId: sessionObjectId, + messageId: storedMessageId, + userId: userObjectId, + }, + { + $set: { + question: questionText, + answer: reactedMessage.text, + sender: reactedMessage.sender, + react: normalizedReact, + createdAt: TimeHelper.unix2PersianTimeAndDate(Date.now() / 1000), + createdISO: new Date(), + }, + }, + { + upsert: true, // Create if doesn't exist, update if it does + new: true, + }, + ); + } + + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + sessionId: result._id, + chatTitle: result.chatTitle, + count: `${result.messages.filter((message) => message.sender === Sender.Bot).length}/20`, + time: result.createdAt[0], + date: result.createdAt[1], + messages: result.messages, + }); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + private toStoredMessageId(messageId: string): Types.ObjectId | string { + if ( + Types.ObjectId.isValid(messageId) && + String(new Types.ObjectId(messageId)) === String(messageId) + ) { + return new Types.ObjectId(messageId); + } + return messageId; + } + + public async insertNewChatNewStructure(sessionId, text, sender, now) { + const newMessage = { + messageId: new Types.ObjectId(), + text: text, + sender: sender, + react: sender === Sender.User ? 'Nothing' : null, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + }; + + await this.session.updateOne( + { _id: sessionId }, + { + $push: { + messages: newMessage, + }, + }, + ); + } + + public async editChatMessage( + sessionId: string, + messageId: string, + newMessageText: string, + ): Promise { + const sessionObjectId = new Types.ObjectId(sessionId); + const storedMessageId = this.toStoredMessageId(messageId); + + const result = await this.session.findOneAndUpdate( + { + _id: sessionObjectId, + 'messages.messageId': storedMessageId, + }, + { + $set: { + 'messages.$.text': newMessageText, + 'messages.$.edited': true, + }, + }, + { new: true }, + ); + + if (!result) { + return null; + } + + // Find the updated message within the session's messages array + const updatedMessage = result.messages.find( + (msg) => msg.messageId.toString() === messageId, + ); + return { + ...updatedMessage, + sender: updatedMessage.sender, // Ensure sender role is explicitly returned + }; } + + async ask(body: UserAskQuestion, user) { + try { + if (!body.question) { + throw new HttpException('question_required', HttpStatus.BAD_REQUEST); + } + + const now = Date.now() / 1000; + + if (!body.sessionId) { + return await this.handleNewSession(body, user, now); + } else { + return await this.handleExistingSession(body, user, now); + } + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async handleNewSession(body, user, now) { + try { + console.log('new session'); + let aiResponse; + let isAiServiceAvailable = true; + + try { + const timeoutMs = parseInt(process.env.AI_SERVICE_TIMEOUT || '65000', 10); // Default 60 seconds + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject( + new HttpException('ai_service_timeout', HttpStatus.GATEWAY_TIMEOUT), + ); + }, timeoutMs); + }); + + const aiRequest = await this.buildAiRequestPayload(body, user, []); + + aiResponse = await Promise.race([ + this.aiService.ask(aiRequest, null), + timeoutPromise, + ]); + + if (aiResponse.statusCode == 500) { + isAiServiceAvailable = false; + } + } catch (aiError) { + // Catch any AI service errors (timeout, network errors, 500 errors, etc.) + console.log('AI service error caught:', aiError); + isAiServiceAvailable = false; + } + + let messages; + if (!isAiServiceAvailable) { + // AI service is unavailable, insert fallback message + messages = [ + { + messageId: new Types.ObjectId(), + text: body.question, + sender: Sender.User, + react: 'Nothing', + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + }, + { + messageId: new Types.ObjectId(), + text: "به دلیل اختلال در سیستم زیر ساخت های کشور سرویس هوش مصنوعی در دسترس نیست. لطفا جهت دریافت راهنمایی به کارشناس متصل شوید. ", + sender: Sender.Bot, + react: null, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + }, + ]; + } else { + // AI service responded successfully + messages = [ + { + messageId: new Types.ObjectId(), + text: body.question, + sender: Sender.User, + react: 'Nothing', + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + }, + { + messageId: new Types.ObjectId(), + text: aiResponse, + sender: Sender.Bot, + react: null, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + }, + ]; + } + + const userId = + typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; + + const newSession = { + _id: new Types.ObjectId(), + userId: userId, + chatTitle: body.question, // TODO: AI wrap-up needed + connectedToExpert: false, + onlineChatClosed: false, + chatClosed: false, + expert: '', + expertRate: null, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + messages: messages, + }; + + await this.session.create(newSession); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + sessionId: newSession._id, + count: `1/20`, + date: TimeHelper.unix2PersianTimeAndDate(now)[1], + time: TimeHelper.unix2PersianTimeAndDate(now)[0], + question: body.question, + answer: messages[1].text, + history: newSession.messages, + }); + + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async handleExistingSession(body, user, now) { + try { + const sessionIdToFind = new Types.ObjectId(body.sessionId); + const userId = + typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; + const newSession = await this.session + .findOne({ + _id: sessionIdToFind, + userId: userId, + }) + .exec(); + + if (!newSession) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + if (newSession.chatClosed) { + throw new HttpException( + 'session_limit_reached', + HttpStatus.BAD_REQUEST, + ); + } + + let aiResponse; + let isAiServiceAvailable = true; + const fallbackMessage = "به دلیل اختلال در سیستم زیر ساخت های کشور سرویس هوش مصنوعی در دسترس نیست. لطفا جهت دریافت راهنمایی به کارشناس متصل شوید. "; + + try { + // Take last 6 messages (3 complete user-assistant pairs) instead of 7 + // to ensure we have complete pairs for chat_history_raw + const historyToSend = newSession.messages.slice(-6); // Get the last 6 messages (3 pairs) + + const transformedData = await this.buildAiRequestPayload( + body, + user, + this.transformChatHistory(historyToSend), + ); + + aiResponse = await this.aiService.ask( + transformedData, + body.sessionId, + ); + + if (aiResponse.statusCode == 500) { + isAiServiceAvailable = false; + } + } catch (aiError) { + // Catch any AI service errors (network errors, 500 errors, etc.) + console.log('AI service error caught in existing session:', aiError); + isAiServiceAvailable = false; + } + + // Insert user question + await this.insertNewChatNewStructure( + sessionIdToFind, + body.question, + Sender.User, + now, + ); + + // Insert AI response or fallback message + const responseText = isAiServiceAvailable ? aiResponse : fallbackMessage; + await this.insertNewChatNewStructure( + sessionIdToFind, + responseText, + Sender.Bot, + now, + ); + + const updatedSession = await this.session + .findOne({ + _id: sessionIdToFind, + userId: userId, + }) + .exec(); + + const botMessageCount = updatedSession.messages.filter( + (message) => message.sender === Sender.Bot, + ).length; + + if (botMessageCount >= 20) { + await this.session.updateOne( + { _id: sessionIdToFind }, + { $set: { chatClosed: true } }, + ); + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + sessionId: updatedSession._id, + count: `${botMessageCount}/20`, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + question: body.question, + answer: responseText, + history: updatedSession.messages, + }); + + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + transformChatHistory(messages) { + const history = []; + for (let i = 0; i < messages.length; i += 2) { + const userMessage = messages[i]; + const botMessage = messages[i + 1]; + + if (userMessage && botMessage) { + history.push({ + user: userMessage.text, + assistant: botMessage.text, + // userCreatedAt: userMessage.createdAt, + // botCreatedAt: botMessage.createdAt, + }); + } + } + return history; + } + + private async buildAiRequestPayload( + body: UserAskQuestion, + user: { _id: Types.ObjectId | string }, + chatHistory: { user: string; assistant: string }[], + ) { + const userId = + typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; + const userDoc = await this.user + .findById(userId) + .select('nationalCode') + .lean() + .exec(); + + const { user_insurance_data, user_installments_data } = + await this.policiesService.getUserDataForAi( + userId, + userDoc?.nationalCode, + ); + + return { + user_input: body.question, + chat_history_raw: chatHistory, + user_insurance_data, + user_installments_data, + }; + } + + async closeChatSession(userId, sessionId) { + // ? migrated + try { + // Close the chat session in the old structure ** old-struc ** + await this.user.updateOne( + { _id: userId }, + { + $set: { + 'chat.$[chatElement].chatClosed': true, + }, + }, + { + arrayFilters: [ + { 'chatElement.sessionId': new Types.ObjectId(sessionId) }, + ], + }, + ); + + // ** old-struc ** + + // Close the chat session in the new structure ** new-struc ** + const sessionIdToFind = new Types.ObjectId(sessionId); + await this.session.updateOne( + { _id: sessionIdToFind, userId: userId }, + { $set: { chatClosed: true } }, + ); + + // Log the new structure data for verification ** new-struc ** + await this.session + .findOne({ + _id: sessionIdToFind, + userId: userId, + }) + .exec(); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async findOneUser(filter: FilterQuery): Promise { + return await this.user.findOne(filter); + } + + async updateOneUser( + filter: FilterQuery, + update: UpdateQuery, + ): Promise { + return await this.user.updateOne(filter, update).lean(); + } + + // public async toggleExpertActions( + // userId: string, + // sessionId: string, + // field: string, + // ): Promise { + // try { + // // Validate the field + // if (!['connectedToExpert', 'onlineChatClosed'].includes(field)) { + // throw new Error('Invalid field specified'); + // } + + // // Toggle the field in the new structure + // const sessionIdToFind = new Types.ObjectId(sessionId); + // const result = await this.session.updateOne( + // { _id: sessionIdToFind, userId: userId }, + // { $set: { [field]: true } }, + // ); + + // // Check if the update was successful + // if (result.matchedCount === 0) { + // throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + // } + + // // Log the new structure data for verification + // const updatedSession = await this.session + // .findOne({ + // _id: sessionIdToFind, + // userId: userId, + // }) + // .exec(); + // } catch (err) { + // console.log(err); + // throw new BaseResponseDTO(err.status, err.response, null); + // } + // } + public async toggleExpertActions( + userId: string, + sessionId: string, + field: string, + expertId?: string, + ): Promise { + try { + console.log('toggleExpertActions called with:', { userId, sessionId, field, expertId }); + if (!['connectedToExpert', 'onlineChatClosed'].includes(field)) { + throw new Error('Invalid field specified'); + } + + // Additional validation for expertId when field is connectedToExpert + if (field === 'connectedToExpert' && !expertId) { + throw new Error('expertId is required when field is connectedToExpert'); + } + + const sessionIdToFind = new Types.ObjectId(sessionId); + const userID = + typeof userId === 'string' ? new Types.ObjectId(userId) : userId; + + const session = await this.session.findOne({ _id: sessionIdToFind, userId: userID }).exec(); + + if (!session) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + // New validation logic + if (field === 'connectedToExpert' && session.connectedToExpert && session.chatClosed) { + console.error('User already connected to expert in a closed session for sessionId:', sessionId); + throw new HttpException('you_have_connected_to_expert_once', HttpStatus.BAD_REQUEST); + } + + let updateData: any = { [field]: true }; + + // If connecting to expert, get expert username and add to update + if (field === 'connectedToExpert' && expertId) { + console.log('Attempting to find expert with expertId:', expertId); + const expert = await this.admin + .findOne({ _id: new Types.ObjectId(expertId) }, { username: 1 }) + .exec(); + + if (!expert) { + console.error('Expert not found for expertId:', expertId); + throw new HttpException('expert_not_found', HttpStatus.NOT_FOUND); + } + + updateData.expert = expert.username; + } + + // Update the session + console.log('Updating session with sessionIdToFind:', sessionIdToFind, 'and userID:', userID); + const result = await this.session.updateOne( + { _id: sessionIdToFind, userId: userID }, + { $set: updateData }, + ); + + if (result.matchedCount === 0) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + await this.session + .findOne({ + _id: sessionIdToFind, + userId: userID, + }) + .exec(); + } catch (err) { + console.log(err); + if (err instanceof HttpException) { + throw err; + } + throw new BaseResponseDTO(err.status, err.response, null); + } + } +} diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..7e08f69 --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,76 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { APP_GUARD } from '@nestjs/core'; +import { ScheduleModule } from '@nestjs/schedule'; +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import * as dotenv from 'dotenv'; +import { AppGuard } from './auth/auth.guard'; +import { IpRateLimiterGuard } from './auth/guards/ip-rate-limiter.guard'; +import { ApiKeyAuthGuard } from './auth/guards/api-key-auth.guard'; +import { RedisService } from './common/helpers/redis.service'; +import { getAppImports } from './common/helpers/versioning.helper'; +import { SsoService } from './externals/sso/sso.service'; +import { ReportsModule } from './reports/reports.module'; +import { UserManagementModule } from './user-management/user-management.module'; +import { WidgetModule } from './widget/widget.module'; +import { ClientManagementModule } from './client-management/client-management.module'; +import { BusinessHoursModule } from './business-hours/business-hours.module'; +import { ExpertPreparedMessagesModule } from './expert-prepared-messages/expert-prepared-messages.module'; +import { PoliciesModule } from './policies/policies.module'; + +dotenv.config(); +dotenv.config({ path: `.${process.env.NODE_ENV}.env` }); +console.log(process.env.REDIS_URL); +@Module({ + imports: [ + ConfigModule.forRoot({ + envFilePath: `.local.env`, + isGlobal: true, + }), + ScheduleModule.forRoot(), + ThrottlerModule.forRoot({ + throttlers: [ + { + name: 'auth', + ttl: 60000, + limit: 5, + }, + { + name: 'global', + ttl: 60000, + limit: 100, + }, + ], + ignoreUserAgents: [/.*/], // Optional: ignore certain user agents + skipIf: () => false, // Ensure it runs for all requests + generateKey: (context) => { + // Custom key generator without exposing details + const req = context.switchToHttp().getRequest(); + return req.ip; + }, + }), + ...getAppImports(), + UserManagementModule, + ReportsModule, + WidgetModule, + ClientManagementModule, // Ensure ClientManagementModule is imported + BusinessHoursModule, + ExpertPreparedMessagesModule, + PoliciesModule, + ], + controllers: [], + providers: [ + RedisService, + SsoService, + { + provide: APP_GUARD, + useClass: AppGuard, + }, + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + IpRateLimiterGuard, + ], +}) +export class AppModule {} diff --git a/src/audio/audio-normalization.errors.ts b/src/audio/audio-normalization.errors.ts new file mode 100644 index 0000000..3199acd --- /dev/null +++ b/src/audio/audio-normalization.errors.ts @@ -0,0 +1,6 @@ +export class AudioNormalizationError extends Error { + constructor(message: string) { + super(message); + this.name = 'AudioNormalizationError'; + } +} diff --git a/src/audio/audio-normalization.service.ts b/src/audio/audio-normalization.service.ts new file mode 100644 index 0000000..7d166c9 --- /dev/null +++ b/src/audio/audio-normalization.service.ts @@ -0,0 +1,216 @@ +import { spawn } from 'node:child_process'; +import { stat, unlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AudioNormalizationError } from './audio-normalization.errors'; + +/** + * Normalizes arbitrary browser voice captures to **AAC-LC in MP4** (`.m4a`, `audio/mp4`). + * + * Rationale: AAC-LC in an MP4 container is natively supported by iOS Safari, Android Chrome, + * and desktop browsers; `-movflags +faststart` moves metadata to the head of the file for + * faster start/seek over HTTP. Mono + 96k is a strong voice default (size vs quality). + */ +@Injectable() +export class AudioNormalizationService { + private readonly logger = new Logger(AudioNormalizationService.name); + private readonly ffmpegPath: string; + private readonly ffprobePath: string; + private readonly transcodeTimeoutMs: number; + + constructor(private readonly config: ConfigService) { + this.ffmpegPath = this.config.get('AUDIO_FFMPEG_PATH')?.trim() || 'ffmpeg'; + this.ffprobePath = this.config.get('AUDIO_FFPROBE_PATH')?.trim() || 'ffprobe'; + const raw = this.config.get('AUDIO_TRANSCODE_TIMEOUT_MS'); + const n = raw != null ? Number(raw) : NaN; + this.transcodeTimeoutMs = Number.isFinite(n) && n > 0 ? n : 120_000; + } + + /** + * Transcode any input FFmpeg can demux to single-channel AAC in MP4 (`.m4a`). + * Caller must delete `outputAbsolutePath` and the input temp file after upload. + */ + async transcodeIncomingVoiceToM4a( + inputAbsolutePath: string, + ): Promise<{ outputAbsolutePath: string; durationSec?: number }> { + const outputAbsolutePath = join(tmpdir(), `voice-norm-${randomUUID()}.m4a`); + + const args = [ + '-nostdin', + '-hide_banner', + '-loglevel', + 'error', + '-y', + '-i', + inputAbsolutePath, + '-vn', + '-sn', + '-dn', + '-ac', + '1', + '-ar', + '48000', + '-c:a', + 'aac', + '-b:a', + '96k', + '-profile:a', + 'aac_low', + '-movflags', + '+faststart', + outputAbsolutePath, + ]; + + try { + await this.runProcess(this.ffmpegPath, args, 'ffmpeg'); + } catch (e: unknown) { + await unlink(outputAbsolutePath).catch(() => {}); + throw e; + } + + try { + const st = await stat(outputAbsolutePath); + if (!st.size) { + throw new AudioNormalizationError('normalized_audio_empty'); + } + } catch (e: unknown) { + await unlink(outputAbsolutePath).catch(() => {}); + throw e; + } + + let durationSec: number | undefined; + try { + durationSec = await this.probeDurationSec(outputAbsolutePath); + } catch (err: unknown) { + this.logger.debug(`ffprobe duration skipped: ${(err as Error)?.message}`); + } + + return { outputAbsolutePath, durationSec }; + } + + private async probeDurationSec(filePath: string): Promise { + const args = [ + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'default=noprint_wrappers=1:nokey=1', + filePath, + ]; + const stdout = await this.runProcessCaptureStdout(this.ffprobePath, args, 'ffprobe'); + const n = parseFloat(String(stdout).trim()); + return Number.isFinite(n) ? n : undefined; + } + + private runProcess( + command: string, + args: string[], + label: string, + ): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + let stderr = ''; + const onData = (c: Buffer) => { + stderr += c.toString(); + if (stderr.length > 64_000) stderr = stderr.slice(-32_000); + }; + child.stderr?.on('data', onData); + + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject( + new AudioNormalizationError(`${label}_timeout_${this.transcodeTimeoutMs}ms`), + ); + }, this.transcodeTimeoutMs); + + child.on('error', (err: NodeJS.ErrnoException) => { + clearTimeout(timer); + if (err?.code === 'ENOENT') { + reject( + new AudioNormalizationError( + `${label}_binary_not_found_install_ffmpeg_package`, + ), + ); + } else { + reject(err); + } + }); + + child.on('close', (code) => { + clearTimeout(timer); + if (code === 0) { + resolve(); + return; + } + const tail = stderr.trim().slice(-800); + reject( + new AudioNormalizationError( + `${label}_failed_exit_${code}${tail ? `:${tail}` : ''}`, + ), + ); + }); + }); + } + + private runProcessCaptureStdout( + command: string, + args: string[], + label: string, + ): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (c: Buffer) => { + stdout += c.toString(); + }); + child.stderr?.on('data', (c: Buffer) => { + stderr += c.toString(); + }); + + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject( + new AudioNormalizationError(`${label}_timeout_${this.transcodeTimeoutMs}ms`), + ); + }, this.transcodeTimeoutMs); + + child.on('error', (err: NodeJS.ErrnoException) => { + clearTimeout(timer); + if (err?.code === 'ENOENT') { + reject( + new AudioNormalizationError( + `${label}_binary_not_found_install_ffmpeg_package`, + ), + ); + } else { + reject(err); + } + }); + + child.on('close', (code) => { + clearTimeout(timer); + if (code === 0) { + resolve(stdout); + return; + } + reject( + new AudioNormalizationError( + `${label}_failed_exit_${code}:${stderr.trim().slice(-400)}`, + ), + ); + }); + }); + } +} diff --git a/src/audio/audio.module.ts b/src/audio/audio.module.ts new file mode 100644 index 0000000..276f1cd --- /dev/null +++ b/src/audio/audio.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { AudioNormalizationService } from './audio-normalization.service'; + +@Module({ + providers: [AudioNormalizationService], + exports: [AudioNormalizationService], +}) +export class AudioModule {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..cd39f21 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,147 @@ +import { + Body, + Controller, + Get, + HttpStatus, + Post, + Req, + Request, + Res, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags, ApiQuery } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { ForgetPasswordDTO } from 'src/common/dto/forgetPassword.dto'; +import { + AdminLoginDTO, + UserLoginDTO, + UserVerifyOtpDTO, +} from 'src/common/dto/login.dto'; +import { ResetPasswordDTO } from 'src/common/dto/resetPassword.dto'; +import { AdminGuard } from './guards/admin.guard'; +import { IpRateLimiterGuard } from './guards/ip-rate-limiter.guard'; +import { Public } from './auth.decorator'; +import { AuthService } from './auth.service'; +import { ApiKeyAuthGuard } from './guards/api-key-auth.guard'; + +interface AuthenticatedRequest extends Request { + client?: { + apiKey: string; + name: string; + enName: string; + status: string; + }; +} + +@Controller('/auth') +@ApiTags('Authorization') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @ApiQuery({ name: 'apiKey', required: false, type: String, description: 'API Key for authentication' }) + @Public() + @Throttle({ + default: { + limit: process.env.NODE_ENV === 'production' ? 3 : 100, + ttl: 60000, + }, + }) + @UseGuards(IpRateLimiterGuard, ApiKeyAuthGuard) + @Post('/user-login') + async login(@Body() body: UserLoginDTO, @Request() req: AuthenticatedRequest) { + const origin = req.headers['origin'] as string; + + console.log(req.client) + if (req.client && req.client.enName === 'Saman-Insurance') { + // Call loginV4 or a specific function for API Key 1 + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.loginV4(body); + } else if (req.client && req.client.enName === 'Parsian-Insurance') { + // Call loginV3 or a specific function for API Key 2 + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.loginParsian(body); + } else if(req.client && req.client.enName === 'Saramad-Insurance'){ + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.loginSaramad(body); + } else if(req.client && req.client.enName === 'Tamasino'){ + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.loginV4(body); + } else if(req.client && req.client.enName === 'Asia-Insurance'){ + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.loginAsia(body); + } + } + + @ApiQuery({ name: 'apiKey', required: false, type: String, description: 'API Key for authentication' }) + @Public() + @Throttle({ default: { limit: 5, ttl: 120000 } }) // 5 requests per 2 minutes + @UseGuards(IpRateLimiterGuard,ApiKeyAuthGuard) + @Post('/user-login-verify') + async userVerify(@Body() body: UserVerifyOtpDTO, @Request() req) { + const origin = req.headers['origin'] as string; + console.log(req.client) + console.log('req.client') + if(body.mobile === "09226187419"){ + return this.authService.loginVerifyTest(body); + }else if (req.client && req.client.enName === 'Saman-Insurance' || req.client.enName==='Saramad-Insurance') { + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.userVerify(body); + } else if (req.client && req.client.enName === 'Parsian-Insurance') { + // Call loginV3 or a specific function for API Key 2 + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.userVerify(body); + }else if(req.client && req.client.enName === 'Tamasino'){ + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.userVerify(body); + }else if(req.client && req.client.enName === 'Asia-Insurance'){ + console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`); + return this.authService.userVerify(body); + } + } + + // @ApiBearerAuth() + @Public() + // @Throttle({ + // default: { + // limit: process.env.NODE_ENV === 'production' ? 3 : 100, + // ttl: 60000, + // }, + // })/ + @Throttle({ default: { limit: 5, ttl: 120000 } }) + @UseGuards(AdminGuard) + @Post('/admin-login') + async adminLogin(@Body() body: AdminLoginDTO, @Request() req) { + return new BaseResponseDTO(HttpStatus.ACCEPTED, 'SUCCESS', { + accessToken: req.token, + role: req.admin.role, + _id: req.admin._id, + username: req.admin.username, + }); + } + + @ApiQuery({ name: 'apiKey', required: false, type: String, description: 'API Key for authentication' }) + @Public() + @UseGuards(IpRateLimiterGuard, ApiKeyAuthGuard) + @Post('/admin-forget-password') + async adminRecoveryPassword(@Body() email: ForgetPasswordDTO) { + return this.authService.forgetPassword(email); + } + @ApiQuery({ name: 'apiKey', required: false, type: String, description: 'API Key for authentication' }) + @Public() + @UseGuards(IpRateLimiterGuard,ApiKeyAuthGuard) + @Post('/admin-reset-password') + async adminResetPassword( + @AdminIdentity() adminIdentity, + @Body() Body: ResetPasswordDTO, + ) { + return this.authService.resetPassword(Body, adminIdentity); + } + + @Get('logout') + @ApiBearerAuth() + async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) { + return this.authService.logout(req, res); + } +} diff --git a/src/auth/auth.decorator.ts b/src/auth/auth.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/src/auth/auth.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/auth/auth.guard.ts b/src/auth/auth.guard.ts new file mode 100644 index 0000000..bee5af3 --- /dev/null +++ b/src/auth/auth.guard.ts @@ -0,0 +1,62 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { Request } from 'express'; +import { RedisService } from 'src/common/helpers/redis.service'; +import { EncryptionHelper } from 'src/common/tools/encryption-helper'; +import { IS_PUBLIC_KEY } from './auth.decorator'; + +@Injectable() +export class AppGuard implements CanActivate { + constructor( + private readonly jwtService: JwtService, + private readonly reflector: Reflector, + private readonly redisService: RedisService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + if (!token) { + throw new UnauthorizedException('توکن الزامی است'); + } + + try { + const isBlacklisted = await this.redisService.isTokenBlacklisted(token); // ✅ Using service method + if (isBlacklisted) { + throw new UnauthorizedException('توکن در لیست سیاه قرار دارد'); + } + + const decryptedToken = EncryptionHelper.decrypt(token); + const payload = await this.jwtService.verifyAsync(decryptedToken, { + secret: process.env.auth_jwt_secret, + // issuer: process.env.auth_jwt_issuer, + }); + // await bcrypt. JSON.parse(payload) + request['user'] = payload; + } catch { + throw new UnauthorizedException('عدم احراز هویت'); + } + return true; + } + + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + + return type === 'Bearer' ? token : undefined; + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..23d0b79 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,32 @@ +import { Module } from '@nestjs/common'; +import { JwtModule, JwtService } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { RedisService } from 'src/common/helpers/redis.service'; +import { DatabaseModule } from 'src/database/database.module'; +import { SmsModule } from 'src/externals/sms/sms.module'; +import { SsoModule } from 'src/externals/sso/sso.module'; +import { ClientManagementModule } from 'src/client-management/client-management.module'; +import { PoliciesModule } from 'src/policies/policies.module'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { LocalStrategy } from './local.strategy'; + +@Module({ + imports: [ + PassportModule, + DatabaseModule, + SsoModule, + SmsModule, + ClientManagementModule, + PoliciesModule, + JwtModule.register({ + secret: '@#@!#$@#!TOKEN$#$@!#()(^&', + global: true, + signOptions: { expiresIn: '1d' }, + }), + ], + providers: [AuthService, LocalStrategy, JwtService, RedisService], + exports: [AuthService, RedisService], + controllers: [AuthController], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..d37eafe --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,1287 @@ +import * as crypto from 'node:crypto'; +import * as https from 'node:https'; +import { + BadRequestException, + HttpException, + HttpStatus, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { JwtService } from '@nestjs/jwt'; +import axios from 'axios'; +import { Model } from 'mongoose'; +import { AdminService } from 'src/api/admin/admin.service'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { + UserLoginDTO, + AdminLoginDTO, + UserVerifyOtpDTO, +} from 'src/common/dto/login.dto'; +import { ForgetPasswordDTO } from 'src/common/dto/forgetPassword.dto'; +import { ResetPasswordDTO } from 'src/common/dto/resetPassword.dto'; +import { RedisService } from 'src/common/helpers/redis.service'; +import { AuditLogService } from 'src/common/services/audit-log.service'; +import { EncryptionHelper } from 'src/common/tools/encryption-helper'; +import { Role } from 'src/common/types/role.type'; +import { UserModel } from 'src/database/model/user.model'; +import { SamanSmsService } from 'src/externals/sms/saman.service'; +import { SsoService } from 'src/externals/sso/sso.service'; +import { PoliciesService } from 'src/policies/policies.service'; +import { Identity } from './models/identity.model'; + +@Injectable() +export class AuthService { + constructor( + @InjectModel(UserModel.name) private readonly user: Model, + private readonly jwtService: JwtService, + private readonly adminService: AdminService, + private readonly smsService: SamanSmsService, + private readonly ssoService: SsoService, + private readonly redisService: RedisService, + private readonly policiesService: PoliciesService, + private readonly auditLogService: AuditLogService, + ) {} + async login(userData: UserLoginDTO) { + try { + // if (process.env.SSO_ENABLED == 'true') { + // } else { + if (!userData.mobile) + throw new HttpException('شماره موبایل الزامی است', HttpStatus.BAD_REQUEST); + if (userData.twoFactor && !userData.nationalCode) + throw new HttpException( + 'کد ملی الزامی است', + HttpStatus.BAD_REQUEST, + ); + + let user = await this.user.findOne({ mobile: userData.mobile }); + const otp = Math.floor(10000 + Math.random() * 90000).toString(); + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode ? userData.nationalCode : null, + otp, + role: 'user', + }); + // TODO SMS SENDING FUNCTION + await this.smsService.smsSender(otp, userData.mobile); + return new BaseResponseDTO(HttpStatus.OK, 'sms_sent', { + _id: user._id, + mobile: user.mobile, + otp: otp, + }); + } else { + // TODO SMS SENDING FUNCTION + await this.smsService.smsSender(otp, userData.mobile); + + await this.user.updateOne({ mobile: userData.mobile }, { otp: otp }); + return new BaseResponseDTO(HttpStatus.OK, 'sms_sent', { + _id: user._id, + mobile: user.mobile, + }); + } + // } + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async loginV3(userData: UserLoginDTO) { + try { + const now = new Date(); + if (!userData.mobile) throw new HttpException('شماره موبایل الزامی است', HttpStatus.BAD_REQUEST); + if (userData.twoFactor && !userData.nationalCode) throw new HttpException('کد ملی الزامی است', HttpStatus.BAD_REQUEST); + if (process.env.SSO_ENABLED == 'true' && userData.twoFactor) { + if (!userData.nationalCode) { + throw new HttpException( + 'کد ملی الزامی است', + HttpStatus.BAD_REQUEST, + ); + } + + // Call SSO Signin API + const ssoResponse = await axios.post( + 'https://stg.cp-api.si24.ir/Signin', + { + nationalCode: userData.nationalCode, + }, + { + headers: { 'Content-Type': 'application/json' }, + httpsAgent: new https.Agent({ rejectUnauthorized: false }), // ⚠️ Bypasses SSL verification + timeout: 15000, + }, + ); + + if ( + ssoResponse.data.code !== 0 || + !ssoResponse.data.data?.phoneNumber + ) { + const errorMessage = + ssoResponse.data.errorMessage || 'ورود از طریق سامانه احراز هویت با خطا مواجه شد'; + throw new HttpException(errorMessage, HttpStatus.BAD_REQUEST); + } + + let user = await this.user.findOne({ mobile: userData.mobile }); + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode, + otp: null, // We don't have OTP from SSO + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + ssoEnabled: true, + }); + } else { + await this.user.updateOne( + { mobile: userData.mobile }, + { + nationalCode: userData.nationalCode, + otp: null, + otpCreatedAt: now, + otpAttempts: 0, + ssoEnabled: true, + }, + ); + } + return new BaseResponseDTO(HttpStatus.OK, 'sms_sent_via_sso', { + _id: user._id, + mobile: user.mobile, + ssoEnabled: true, + }); + } else if ( + process.env.SSO_ENABLED == 'true' && + !userData.twoFactor + ) { + console.log('notify'); + await this.handleNormalLogin(userData); + return new BaseResponseDTO(HttpStatus.OK, 'sms_sent_via_notify', { + mobile: userData.mobile, + ssoEnabled: true, + }); + } else { + let user = await this.user.findOne({ mobile: userData.mobile }); + const otp = Math.floor(10000 + Math.random() * 90000).toString(); + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode ? userData.nationalCode : null, + otp, + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + }); + await this.smsService.smsSender(otp, userData.mobile); + return new BaseResponseDTO(HttpStatus.OK, 'sms_sent', { + _id: user._id, + mobile: user.mobile, + }); + } else { + await this.smsService.smsSender(otp, userData.mobile); + await this.user.updateOne( + { mobile: userData.mobile }, + { otp: otp, otpCreatedAt: now, otpAttempts: 0 }, + ); + return new BaseResponseDTO(HttpStatus.OK, 'sms_sent', { + _id: user._id, + mobile: user.mobile, + }); + } + } + } catch (err) { + console.error('Login Error:', err); + + // Handle HttpException (your custom errors) + if (err instanceof HttpException) { + throw new BaseResponseDTO(err.getStatus(), err.message, null); + } + + // Handle Axios errors (from SSO API or Kavenegar API) + if (err.response) { + const errorMessage = + err.response.data?.errorMessage || + err.response.data?.message || + 'خطا در ارتباط با سرویس خارجی'; + const errorStatus = err.response.status || HttpStatus.BAD_REQUEST; + throw new BaseResponseDTO(errorStatus, errorMessage, null); + } + + // Handle request timeout + if (err.code === 'ECONNABORTED') { + throw new BaseResponseDTO( + HttpStatus.REQUEST_TIMEOUT, + 'زمان ارتباط با سامانه احراز هویت به پایان رسید', + null, + ); + } + + // Handle generic errors + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'خطای داخلی سرور', + null, + ); + } + } + + async loginParsian(userData:UserLoginDTO){ + try { + const now = new Date(); + + const user = await this.user.findOne({mobile:userData.mobile}); + const otp = this.generateOtp(); + const message = `کاربر محترم رمز یکبار مصرف شما برای اپلیکیشن بیمه پارسیان +Code: ${otp}`; + let result; + if(!user){ + await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode, + otp, + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + ssoEnabled: false, + }); + const axiosConfig = { + headers: { + 'Content-Type': 'application/json', + 'X-PACKAGE-API-KEY': process.env.PARSIAN_API_KEY, + 'Authorization': `Basic ${process.env.PARSIAN_BASIC_TOKEN}` + }, + }; + const singleSendUri = `${process.env.PARSIAN_SMS_URL}=${userData.mobile}&Message=${message}`; + const response = await axios.get(singleSendUri, axiosConfig); + console.log(response.data) + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + }else { + user.otp = otp; + user.otpCreatedAt= now, + + user.save(); + const axiosConfig = { + headers: { + 'Content-Type': 'application/json', + 'X-PACKAGE-API-KEY': process.env.PARSIAN_API_KEY, + 'Authorization': `Basic ${process.env.PARSIAN_BASIC_TOKEN}` + }, + }; + const singleSendUri = `${process.env.PARSIAN_SMS_URL}=${userData.mobile}&Message=${message}`; + const response = await axios.get(singleSendUri, axiosConfig); + console.log(response.data) + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } + + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + //deprecated + async loginV2(userData: UserLoginDTO) { + try { + this.validateUserData(userData); + + if (process.env.SSO_ENABLED === 'true') { + return await this.handleNormalLogin(userData); + } else { + return await this.handleDevelopmentLogin(userData); + } + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + private validateUserData(userData: UserLoginDTO) { + if (!userData.mobile) { + throw new HttpException('شماره موبایل الزامی است', HttpStatus.BAD_REQUEST); + } + if (userData.twoFactor && !userData.nationalCode) { + throw new HttpException('کد ملی الزامی است', HttpStatus.BAD_REQUEST); + } + } + + private async handleNormalLogin(userData: UserLoginDTO) { + const now = new Date(); + const otp = this.generateOtp(); + const message = `کاربر محترم رمز یکبار مصرف شما برای اپلیکیشن بیمه سامان +Code: ${otp}`; + let user = await this.user.findOne({ mobile: userData.mobile }); + if (!user) { + await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode, + otp: null, // We don't have OTP from SSO + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + ssoEnabled: true, + }); + } else { + await this.user.updateOne( + { mobile: userData.mobile }, + { + nationalCode: userData.nationalCode, + otp: null, + otpCreatedAt: now, + otpAttempts: 0, + ssoEnabled: true, + }, + ); + } + const notify = await this.ssoService.notifyService( + userData.mobile, + message, + 'otp', + ); + console.log(notify); + console.log('notify in loginV3'); + + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } + + private async handleDevelopmentLogin(userData: UserLoginDTO) { + let user = await this.user.findOne({ mobile: userData.mobile }); + const otp = this.generateOtp(); + + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode || null, + otp, + role: 'user', + }); + } else { + await this.user.updateOne({ mobile: userData.mobile }, { otp }); + } + + await this.sendSms(otp, userData.mobile); + + return new BaseResponseDTO(HttpStatus.OK, 'sms_sent', { + _id: user._id, + mobile: user.mobile, + }); + } + + private generateOtp(): string { + return Math.floor(10000 + Math.random() * 90000).toString(); + } + + private async sendSms(otp: string, mobile: string) { + await this.smsService.smsSender(otp, mobile); + } + + private async clearOtp(mobile: string): Promise { + await this.user.updateOne( + { mobile }, + { + otp: null, + otpCreatedAt: null, + otpAttempts: 0, + }, + ); + } + + async userVerify(userData: UserVerifyOtpDTO) { + try { + if (!userData.mobile || !userData.otp) + throw new HttpException('اطلاعات ورودی ناقص است', HttpStatus.BAD_REQUEST); + const user = await this.user + .findOne({ mobile: userData.mobile }) + .select('+otp +otpCreatedAt +otpAttempts +nationalCode'); + if (!user) + throw new HttpException('کاربر یافت نشد', HttpStatus.NOT_FOUND); + + const ssoEnabled = process.env.SSO_ENABLED; + + if (ssoEnabled && user.otp == null) { + // SSO verification flow + if (!user.nationalCode) { + throw new HttpException( + 'کد ملی یافت نشد', + HttpStatus.BAD_REQUEST, + ); + } + + try { + // Call SSO OTP verification API + const ssoVerifyResponse = await axios.post( + process.env.SAMAN_SSO_VERIFY_URL, + { + nationalCode: user.nationalCode, + otp: userData.otp, + }, + { + headers: { 'Content-Type': 'application/json' }, + httpsAgent: new https.Agent({ + rejectUnauthorized: false, // ⚠️ Only for testing! + }), + timeout: 15000, + }, + ); + + if (ssoVerifyResponse.data.code !== 0) { + const errorCode = ssoVerifyResponse.data.code; + let errorMessage = + ssoVerifyResponse.data.errorMessage || 'خطا در تایید احراز هویت'; + switch (errorCode) { + case 602: + errorMessage = 'کد وارد شده صحیح نمی باشد.'; + break; + case 614: + errorMessage = 'لطفا 2 دقیقه دیگر مجدد تلاش نمایید'; + break; + case 600: + errorMessage = 'رمز یکبارمصرف منقضی شده است.'; + break; + case 615: + errorMessage = 'تعداد درخواستهای شما بیشتر از حد مجاز است.'; + break; + case 612: + errorMessage = 'کد ملی وارد شده معتبر نمی باشد'; + break; + default: + errorMessage = + ssoVerifyResponse.data.errorMessage || 'خطا در احراز هویت'; + console.log( + `Unhandled SSO error code: ${errorCode}`, + ssoVerifyResponse.data, + ); + break; + } + throw new HttpException(errorMessage, HttpStatus.BAD_REQUEST); + } + + const samanToken = ssoVerifyResponse.data.data.access_token; + const decodedToken = this.jwtService.decode(samanToken); + + const updatePayload: any = { + ssoEnabled: true, + lastLogin: new Date(), + }; + + if (!user.name && decodedToken.given_name) { + updatePayload.name = decodedToken.given_name; + } + if (!user.family && decodedToken.family_name) { + updatePayload.family = decodedToken.family_name; + } + if (!user.nationalCode && decodedToken.nationalCode) { + updatePayload.nationalCode = decodedToken.nationalCode; + } + + // Update user document if needed + if (Object.keys(updatePayload).length > 2) { + // More than just ssoEnabled and lastLogin + await this.user.updateOne( + { _id: user._id }, + { $set: updatePayload }, + ); + } + + await this.clearOtp(user.mobile); + const accessToken = await this.generateJwtToken(user, Role.User); + + // Fetch and store policies if SSO enabled, twoFactor is true, and both mobile and nationalCode exist + console.log('=== Policy Fetch Check ==='); + console.log('SSO_ENABLED:', process.env.SSO_ENABLED); + console.log('user.mobile:', user.mobile); + console.log('user.nationalCode:', user.nationalCode); + + if ( + process.env.SSO_ENABLED === 'true' && + user.mobile && + user.nationalCode + ) { + console.log('✅ Starting policy sync for user:', user._id); + try { + const result = + await this.policiesService.fetchAndStorePoliciesWithInstallments( + user._id, + user.nationalCode, + ); + console.log( + `✅ Successfully synced ${result.policiesCount} policies and ${result.installmentsCount} installments for user ${user._id}`, + ); + } catch (error) { + console.error( + `❌ Failed to sync policies for user ${user._id}:`, + error, + ); + } + } else { + console.log('❌ Policy fetch skipped. Conditions not met.'); + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', accessToken); + } catch (error) { + console.error('SSO Verification Error:', { + message: error.message, + code: error.code, + response: error.response?.data, + status: error.response?.status, + statusText: error.response?.statusText, + }); + + if (error instanceof HttpException) { + throw error; + } + + // Check if we have a valid error response from SSO service + if ( + error.response?.data?.code && + error.response?.data?.errorMessage + ) { + const errorCode = error.response.data.code; + let errorMessage = error.response.data.errorMessage; + + // Map the error code to a specific message if needed + switch (errorCode) { + case 602: + errorMessage = 'کد وارد شده صحیح نمی باشد.'; + break; + case 614: + errorMessage = 'لطفا 2 دقیقه دیگر مجدد تلاش نمایید'; + break; + case 600: + errorMessage = 'رمز یکبارمصرف منقضی شده است.'; + break; + case 615: + errorMessage = 'تعداد درخواستهای شما بیشتر از حد مجاز است.'; + break; + case 612: + errorMessage = 'کد ملی وارد شده معتبر نمی باشد'; + break; + } + + throw new HttpException(errorMessage, HttpStatus.BAD_REQUEST); + } + + // If we don't have a valid error response, handle the 530 status code + if (error.response?.status === 530) { + throw new HttpException( + 'خطا در ارتباط با سرور احراز هویت', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + throw new HttpException( + error.response?.data?.errorMessage || + 'خطا در ارتباط با سرور احراز هویت', + error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } else { + // Original non-SSO verification flow + if (!user.otp || !user.otpCreatedAt) + throw new HttpException('رمز یکبارمصرف یافت نشد', HttpStatus.BAD_REQUEST); + + const otpAgeMs = Date.now() - user.otpCreatedAt.getTime(); + if (otpAgeMs > 120000) { + await this.clearOtp(user.mobile); + throw new HttpException('رمز یکبارمصرف منقضی شده است.', HttpStatus.BAD_REQUEST); + } + + if (user.otpAttempts >= 3) { + throw new HttpException( + 'تعداد درخواستهای شما بیشتر از حد مجاز است.', + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + if (!UserModel.validateOtp(userData.otp, user.otp)) { + await this.user.updateOne( + { mobile: userData.mobile }, + { $inc: { otpAttempts: 1 } }, + ); + throw new HttpException('کد وارد شده صحیح نمی باشد.', HttpStatus.BAD_REQUEST); + } + + await this.clearOtp(user.mobile); + const accessToken = await this.generateJwtToken(user, Role.User); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', accessToken); + } + } catch (error) { + console.error('User Verify Error:', error); + + if (error instanceof HttpException) { + throw new BaseResponseDTO(error.getStatus(), error.message, null); + } + + if (error.response?.data) { + throw new BaseResponseDTO( + error.response.status || HttpStatus.INTERNAL_SERVER_ERROR, + error.response.data.errorMessage || 'خطا در احراز هویت', + null, + ); + } + + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'خطا در احراز هویت', + null, + ); + } + } + + async loginVerifyTest(userData: UserVerifyOtpDTO) { + try { + if (!userData.mobile || !userData.otp) + throw new HttpException('اطلاعات ورودی ناقص است', HttpStatus.BAD_REQUEST); + const user = await this.user + .findOne({ mobile: userData.mobile }) + .select('+otp +otpCreatedAt +otpAttempts +nationalCode'); + if (!user) + throw new HttpException('کاربر یافت نشد', HttpStatus.NOT_FOUND); + const accessToken = await this.generateJwtToken(user, Role.User); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', accessToken); + } catch (error) { + console.error('User Verify Error:', error); + + if (error instanceof HttpException) { + throw new BaseResponseDTO(error.getStatus(), error.message, null); + } + + if (error.response?.data) { + throw new BaseResponseDTO( + error.response.status || HttpStatus.INTERNAL_SERVER_ERROR, + error.response.data.errorMessage || 'خطا در احراز هویت', + null, + ); + } + + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'خطا در احراز هویت', + null, + ); + } + } + + async loginTamasino(userData:UserLoginDTO){ + try { + const now = new Date(); + const otp = this.generateOtp(); + // const message = `کاربر محترم رمز یکبار مصرف شما برای چت بات هولدینگ سرآمد\nCode: ${otp}`; +const url = `https://api.kavenegar.com/v1/${process.env.KN_KEY}/verify/lookup.json` + let user = await this.user.findOne({ mobile: userData.mobile }); + + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode ? userData.nationalCode : null, + otp: + userData.twoFactor && process.env.SSO_ENABLED == 'true' + ? null + : otp, // We don't have OTP from SSO + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + }); + } + } catch (err) { + console.error('Login Error:', err); + + if (err instanceof HttpException) { + throw new BaseResponseDTO(err.getStatus(), err.message, null); + } + + if (err.response) { + const errorMessage = + err.response.data?.errorMessage || + err.response.data?.message || + 'خطا در ارتباط با سرویس خارجی'; + const errorStatus = err.response.status || HttpStatus.BAD_REQUEST; + throw new BaseResponseDTO(errorStatus, errorMessage, null); + } + + if (err.code === 'ECONNABORTED') { + throw new BaseResponseDTO( + HttpStatus.REQUEST_TIMEOUT, + 'زمان ارتباط با سامانه احراز هویت به پایان رسید', + null, + ); + } + + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'خطای داخلی سرور', + null, + ); + } + } + + async loginSaramad(userData: UserLoginDTO) { + try { + const now = new Date(); + const otp = this.generateOtp(); + const message = `کاربر محترم رمز یکبار مصرف شما برای چت بات هولدینگ سرآمد\nCode: ${otp}`; + + let user = await this.user.findOne({ mobile: userData.mobile }); + + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode ? userData.nationalCode : null, + otp: + userData.twoFactor && process.env.SSO_ENABLED == 'true' + ? null + : otp, // We don't have OTP from SSO + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + }); + } else { + await this.user.updateOne( + { mobile: userData.mobile }, + { + nationalCode: userData.nationalCode, + otp: + userData.twoFactor && process.env.SSO_ENABLED == 'true' + ? null + : otp, + otpCreatedAt: now, + otpAttempts: 0, + ssoEnabled: true, + }, + ); + } + //* using nationalCode and mobile (sso service) + console.info(`Log in using TFA: ${userData.twoFactor}`); + console.info( + `Mobile: ${userData.mobile} , nationalCode: ${userData.nationalCode}`, + ); + if (process.env.SSO_ENABLED == 'true' && userData.twoFactor) { + console.info('here in sso enabled and two factor'); + if (!userData.nationalCode) + throw new HttpException( + 'کد ملی الزامی است', + HttpStatus.BAD_REQUEST, + ); + const ssoResponse = await axios.post( + process.env.SAMAN_SSO_URL, + { + nationalCode: userData.nationalCode, + }, + { + headers: { 'Content-Type': 'application/json' }, + httpsAgent: new https.Agent({ rejectUnauthorized: false }), // ⚠️ Bypasses SSL verification + timeout: 15000, + }, + ); + if ( + ssoResponse.data.code !== 0 || + !ssoResponse.data.data?.phoneNumber + ) { + const errorMessage = + ssoResponse.data.errorMessage || 'ورود از طریق سامانه احراز هویت با خطا مواجه شد'; + throw new HttpException(errorMessage, HttpStatus.BAD_REQUEST); + } + console.info(`sms sent to ${userData.mobile} using SSO service `); + + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } else if ( + process.env.SSO_ENABLED == 'true' && + !userData.twoFactor + ) { + //* using just mobile (notify service) + console.info('here in sso enabled and one factor in notify'); + const notify = await this.ssoService.notifyService( + userData.mobile, + message, + 'otp', + ); + console.info(notify); + console.info(`sms sent to ${userData.mobile} using notify service `); + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } else { + console.info('here in development'); + let sms = await this.smsService.smsSender(otp, userData.mobile); + console.log(sms); + console.info( + `sms sent to ${userData.mobile} using kavehnegar service `, + ); + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } + } catch (err) { + console.error('Login Error:', err); + + if (err instanceof HttpException) { + throw new BaseResponseDTO(err.getStatus(), err.message, null); + } + + if (err.response) { + const errorMessage = + err.response.data?.errorMessage || + err.response.data?.message || + 'خطا در ارتباط با سرویس خارجی'; + const errorStatus = err.response.status || HttpStatus.BAD_REQUEST; + throw new BaseResponseDTO(errorStatus, errorMessage, null); + } + + if (err.code === 'ECONNABORTED') { + throw new BaseResponseDTO( + HttpStatus.REQUEST_TIMEOUT, + 'زمان ارتباط با سامانه احراز هویت به پایان رسید', + null, + ); + } + + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'خطای داخلی سرور', + null, + ); + } + } + + async loginAsia(userData: UserLoginDTO) { + try { + const now = new Date(); + const otp = this.generateOtp(); + const baseMessage = `کاربر محترم کد ورود شما برای بیمه آسیا`; + const fullMessage = `${baseMessage}\nCode: ${otp}`; + + let user = await this.user.findOne({ mobile: userData.mobile }); + + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode ? userData.nationalCode : null, + otp, + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + ssoEnabled: false, + }); + } else { + user.otp = otp; + user.otpCreatedAt = now; + user.otpAttempts = 0; + await user.save(); + } + + const recipientsArr = [userData.mobile]; + + const axiosConfig = { + headers: { + 'Content-Type': 'application/json', + 'cache-control': 'no-cache', + }, + }; + + // Magfa basic auth for Asia Insurance – can be moved to env if needed + axios.defaults.headers.post['Authorization'] = + process.env.ASIA_MAGFA_BASIC_TOKEN || + 'Basic QmltZWFzaWEvTWFnZmE6X0JpbWVoXzEyOV8='; + + const body = { + senders: ['30007151'], + recipients: recipientsArr, + messages: [fullMessage], + }; + + const encodedUri = encodeURI( + 'https://sms.magfa.com/api/http/sms/v2/send', + ); + + const response = await axios.post(encodedUri, body, axiosConfig); + console.log('Asia Insurance SMS response:', response.data); + + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } catch (err) { + console.error('Login Asia Error:', err); + + if (err instanceof HttpException) { + throw new BaseResponseDTO(err.getStatus(), err.message, null); + } + + if (err.response) { + const errorMessage = + err.response.data?.errorMessage || + err.response.data?.message || + 'خطا در ارتباط با سرویس خارجی'; + const errorStatus = err.response.status || HttpStatus.BAD_REQUEST; + throw new BaseResponseDTO(errorStatus, errorMessage, null); + } + + if (err.code === 'ECONNABORTED') { + throw new BaseResponseDTO( + HttpStatus.REQUEST_TIMEOUT, + 'sms_service_timeout', + null, + ); + } + + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'خطای داخلی سرور', + null, + ); + } + } + + async loginV4(userData: UserLoginDTO) { + try { + const now = new Date(); + const otp = this.generateOtp(); + const message = `کاربر محترم رمز یکبار مصرف شما برای اپلیکیشن بیمه سامان\nCode: ${otp}`; + + let user = await this.user.findOne({ mobile: userData.mobile }); + + if (!user) { + user = await this.user.create({ + mobile: userData.mobile, + nationalCode: userData.nationalCode ? userData.nationalCode : null, + otp: + userData.twoFactor && process.env.SSO_ENABLED == 'true' + ? null + : otp, // We don't have OTP from SSO + otpCreatedAt: now, + otpAttempts: 0, + role: 'user', + }); + } else { + await this.user.updateOne( + { mobile: userData.mobile }, + { + nationalCode: userData.nationalCode, + otp: + userData.twoFactor && process.env.SSO_ENABLED == 'true' + ? null + : otp, + otpCreatedAt: now, + otpAttempts: 0, + ssoEnabled: true, + }, + ); + } + //* using nationalCode and mobile (sso service) + console.info(`Log in using TFA: ${userData.twoFactor}`); + console.info( + `Mobile: ${userData.mobile} , nationalCode: ${userData.nationalCode}`, + ); + if (process.env.SSO_ENABLED == 'true' && userData.twoFactor) { + console.info('here in sso enabled and two factor'); + if (!userData.nationalCode) + throw new HttpException( + 'کد ملی الزامی است', + HttpStatus.BAD_REQUEST, + ); + const ssoResponse = await axios.post( + process.env.SAMAN_SSO_URL, + { + nationalCode: userData.nationalCode, + }, + { + headers: { 'Content-Type': 'application/json' }, + httpsAgent: new https.Agent({ rejectUnauthorized: false }), // ⚠️ Bypasses SSL verification + timeout: 15000, + }, + ); + if ( + ssoResponse.data.code !== 0 || + !ssoResponse.data.data?.phoneNumber + ) { + const errorMessage = + ssoResponse.data.errorMessage || 'ورود از طریق سامانه احراز هویت با خطا مواجه شد'; + throw new HttpException(errorMessage, HttpStatus.BAD_REQUEST); + } + console.info(`sms sent to ${userData.mobile} using SSO service `); + + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } else if ( + process.env.SSO_ENABLED == 'true' && + !userData.twoFactor + ) { + //* using just mobile (notify service) + console.info('here in sso enabled and one factor in notify'); + const notify = await this.ssoService.notifyService( + userData.mobile, + message, + 'otp', + ); + console.info(notify); + console.info(`sms sent to ${userData.mobile} using notify service `); + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } else { + console.info('here in development'); + let sms = await this.smsService.smsSender(otp, userData.mobile); + console.log(sms); + console.info( + `sms sent to ${userData.mobile} using kavehnegar service `, + ); + return new BaseResponseDTO(HttpStatus.OK, 'otp_sent', { + mobile: userData.mobile, + }); + } + } catch (err) { + console.error('Login Error:', err); + + if (err instanceof HttpException) { + throw new BaseResponseDTO(err.getStatus(), err.message, null); + } + + if (err.response) { + const errorMessage = + err.response.data?.errorMessage || + err.response.data?.message || + 'خطا در ارتباط با سرویس خارجی'; + const errorStatus = err.response.status || HttpStatus.BAD_REQUEST; + throw new BaseResponseDTO(errorStatus, errorMessage, null); + } + + if (err.code === 'ECONNABORTED') { + throw new BaseResponseDTO( + HttpStatus.REQUEST_TIMEOUT, + 'زمان ارتباط با سامانه احراز هویت به پایان رسید', + null, + ); + } + + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'خطای داخلی سرور', + null, + ); + } + } + + async adminLogin(admin: AdminLoginDTO) { + let { username, password } = admin; + let findUsername = await this.adminService.findOneAdmin({ + username: username, + isActive: true, + }); + + if (!findUsername) { + throw new UnauthorizedException('نام کاربری یا رمز عبور نادرست است یا حساب غیرفعال می‌باشد'); + } + + let hashedRawPassword = crypto + .createHash('sha256') + .update(password) + .digest('hex'); + if (hashedRawPassword === findUsername.password) { + return this.generateJwtToken(findUsername, Role.Admin); + } else { + return null; + } + } + + async adminValidate(admin: AdminLoginDTO) { + try { + let validateAdmin = await this.adminService.findOneAdmin({ + username: admin.username, + }); + if (!validateAdmin) { + throw new HttpException('ادمین یافت نشد', HttpStatus.NOT_FOUND); + } + return validateAdmin; + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async findAdminResetToken(token: string) { + try { + let findToken = await this.adminService.findOneAdmin({ + resetToken: token, + }); + if (!findToken) { + throw new HttpException('ادمین یافت نشد', HttpStatus.NOT_FOUND); + } + return findToken; + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async forgetPassword(email: ForgetPasswordDTO) { + try { + // TODO Email Service + let userHasExist = await this.adminService.findOneAdmin({ + username: email.username, + }); + if (!userHasExist) + throw new HttpException('کاربر یافت نشد', HttpStatus.NOT_FOUND); + let token = this.jwtService.sign( + { username: userHasExist.username, time: new Date() }, + { + secret: process.env.auth_jwt_forget_password, + }, + ); + userHasExist.resetToken = token; + await userHasExist.save(); + return new BaseResponseDTO(HttpStatus.ACCEPTED, 'SUCCESS', { + link: `${process.env.BASEURL}?token=${token}`, + }); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async resetPassword(DTO: ResetPasswordDTO, identity: Identity) { + try { + const email = await this.decodeConfirmationToken(DTO.resetToken); + + const user = await this.adminService.findOneAdmin({ username: email }); + if (!user) { + throw new HttpException('کاربر یافت نشد', HttpStatus.NOT_FOUND); + } + + user.password = crypto + .createHash('sha256') + .update(DTO.newPassword) + .digest('hex'); + user.resetToken = null; + await user.save(); + return new BaseResponseDTO( + HttpStatus.ACCEPTED, + 'SUCCESS', + 'PASSWORD_CHANGED', + ); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + public async decodeConfirmationToken(token: string) { + try { + const payload = await this.jwtService.verify(token, { + secret: process.env.auth_jwt_forget_password, + }); + + if ('username' in payload) { + return payload.username; + } + throw new BadRequestException('درخواست نامعتبر است'); + } catch (error) { + if (error?.name === 'TokenExpiredError') { + throw new HttpException('توکن منقضی شده است', HttpStatus.UNAUTHORIZED); + } + throw new BadRequestException('توکن تایید نامعتبر است'); + } + } + + async generateJwtToken(userData, role: Role) { + // Get expiresIn from environment with fallback + const expiresInProduction = process.env.ACCESS_TOKEN_EXPIRE_TIME_PRODUCTION || '12h'; + const expiresInDevelopment = process.env.ACCESS_TOKEN_EXPIRE_TIME_DEVELOPMENT || '1d'; + + // Remove quotes if present (env vars might have quotes) + const cleanExpiresIn = (value: string) => { + if (!value) return '1d'; // Default fallback + return value.replace(/^['"]|['"]$/g, ''); // Remove surrounding quotes + }; + + const config = { + secret: process.env.auth_jwt_secret, + expiresIn: cleanExpiresIn( + process.env.NODE_ENV === 'production' + ? expiresInProduction + : expiresInDevelopment, + ), + }; + + if (role === Role.User) { + const token = await this.jwtService.signAsync( + { + _id: userData._id, + role: userData.role || role, + mobile: userData.mobile, + createdAt: userData.createdAt, + updatedAt: userData.updatedAt, + }, + config, + ); + + //encrypt token + return EncryptionHelper.encrypt(token); + } else { + const data = { + _id: userData._id, + role: userData.role || role, + mobile: userData.mobile, + name: userData.name, + family: userData.family, + username: userData.username, + createdAt: userData.createdAt, + updatedAt: userData.updatedAt, + }; + const token = await this.jwtService.signAsync(data, config); + //encrypt token + return EncryptionHelper.encrypt(token); + } + } + + // auth.service.ts (or wherever your logout method is) + + async logout(req, res: any) { + try { + const token = req.headers['authorization']?.split(' ')[1]; + if (!token) throw new UnauthorizedException('توکن یافت نشد'); + + const decryptedToken = EncryptionHelper.decrypt(token); + const decoded = this.jwtService.verify(decryptedToken, { + secret: process.env.auth_jwt_secret, + }) as { + _id: string; + role: string; + exp: number; + iat: number; + }; + + const currentTime = Math.floor(Date.now() / 1000); + const ttlInSeconds = decoded.exp - currentTime; + if (ttlInSeconds > 0) { + await this.redisService.blacklistToken(token, ttlInSeconds); // ✅ Using service method + } + + const isStaff = decoded.role && decoded.role !== Role.User; + if (isStaff) { + await this.auditLogService.log({ + action: 'auth.staff_logout', + resource: 'Admin', + resourceId: decoded._id, + userId: decoded._id, + metadata: { role: decoded.role }, + }); + } + + return { message: 'خروج با موفقیت انجام شد' }; + } catch (err) { + console.log(err); + throw new BaseResponseDTO( + err.status || 500, + err.message || 'خروج با خطا مواجه شد', + null, + ); + } + } +} diff --git a/src/auth/guards/admin.guard.ts b/src/auth/guards/admin.guard.ts new file mode 100644 index 0000000..448dee2 --- /dev/null +++ b/src/auth/guards/admin.guard.ts @@ -0,0 +1,274 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, + ForbiddenException, + HttpStatus, + Optional, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { Request } from 'express'; +import { rateLimit } from 'express-rate-limit'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { EncryptionHelper } from 'src/common/tools/encryption-helper'; +import { SKIP_ADMIN_RATE_LIMIT_KEY } from 'src/common/decorators/skip-admin-rate-limit.decorator'; +import { PERMISSIONS_KEY } from 'src/common/decorators/permission.decorator'; +import { ROLES_KEY } from 'src/common/decorators/role.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { isOwnerRole } from 'src/common/types/role.type'; +import { PermissionsService } from 'src/acl/permissions.service'; +import { AuditLogService } from 'src/common/services/audit-log.service'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class AdminGuard implements CanActivate { + private readonly limiter: ReturnType; + + constructor( + private readonly authService: AuthService, + private readonly reflector: Reflector, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + @Optional() private readonly permissionsService?: PermissionsService, + @Optional() private readonly auditLogService?: AuditLogService, + ) { + const isProduction = this.configService.get('NODE_ENV') === 'production'; + + this.limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: isProduction ? 50 : 100, // Stricter in production + standardHeaders: false, + legacyHeaders: false, + skip: () => !isProduction, // Skip rate limiting in non-production + keyGenerator: (req) => { + // Use admin ID if authenticated, otherwise use IP + return req['admin']?._id || req.ip; + }, + handler: (req, res) => { + res.status(429).json({ + statusCode: 429, + message: isProduction + ? 'Too many admin requests' + : 'Dev Mode: Admin rate limit warning (not enforced)', + }); + }, + }); + } + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + const requiredPermissions = this.reflector.getAllAndOverride( + PERMISSIONS_KEY, + [context.getHandler(), context.getClass()], + ); + + // Check if the Admin rate limit should be skipped for this handler/class + const skipAdminRateLimit = this.reflector.getAllAndOverride( + SKIP_ADMIN_RATE_LIMIT_KEY, + [context.getHandler(), context.getClass()], + ); + + // Apply rate limiting only if not skipped + if (!skipAdminRateLimit && !(await this.applyRateLimit(request))) { + return false; + } + + if (this.isLoginPath(request)) { + return this.handleLogin(request); + } else if (this.isForgetPath(request)) { + return this.handleResetPath(request); + } else { + return this.handleAuthentication( + request, + requiredRoles, + requiredPermissions, + ); + } + } + + private async applyRateLimit(request: Request): Promise { + return new Promise((resolve) => { + const mockResponse = { + status: (code) => ({ + json: (data) => { + request.res.status(code).json(data); + resolve(false); + }, + }), + } as any; + + this.limiter(request, mockResponse, (err?: unknown) => { + if (err) { + return resolve(false); + } + resolve(true); + }); + }); + } + + private async handleResetPath(request: Request) { + if (request.url == '/auth/admin-reset-password') { + let resetTokenFromAdmin = await this.authService.findAdminResetToken( + request.body.resetToken, + ); + if (resetTokenFromAdmin) { + request['admin'] = resetTokenFromAdmin; + } + return true; + } else { + const isAdmin = await this.authService.adminValidate(request.body); + if (isAdmin) { + request['admin'] = isAdmin; + } + return true; + } + } + + private async handleLogin(request: Request): Promise { + try { + const isAdmin = await this.authService.adminValidate(request.body); + if (isAdmin) { + request['admin'] = isAdmin; + const token = await this.authService.adminLogin(request.body); + if (token) { + request['token'] = token; + await this.auditLogService?.logHttpRequest( + 'auth.staff_login_success', + request, + { + userId: String(isAdmin._id), + resource: 'Admin', + resourceId: String(isAdmin._id), + metadata: { role: isAdmin.role, username: isAdmin.username }, + }, + ); + return true; + } else { + await this.auditLogService?.logHttpRequest( + 'auth.staff_login_failure', + request, + { + resource: 'Admin', + metadata: { username: request.body?.username, reason: 'bad_password' }, + statusCode: 401, + }, + ); + throw new BaseResponseDTO( + HttpStatus.UNAUTHORIZED, + 'رمز عبور نادرست است', + null, + ); + } + } else { + await this.auditLogService?.logHttpRequest( + 'auth.staff_login_failure', + request, + { + resource: 'Admin', + metadata: { username: request.body?.username, reason: 'not_found' }, + statusCode: 401, + }, + ); + throw new BaseResponseDTO( + HttpStatus.UNAUTHORIZED, + 'اطلاعات ورود نامعتبر است', + null, + ); + } + } catch (err) { + throw new UnauthorizedException(err.message || 'عدم احراز هویت'); + } + } + + private async handleAuthentication( + request: Request, + requiredRoles: string[] | undefined, + requiredPermissions: Permission[] | undefined, + ): Promise { + const token = this.extractTokenFromHeader(request); + + if (!token) { + throw new UnauthorizedException('توکن الزامی است'); + } + + try { + const decryptedToken = EncryptionHelper.decrypt(token); + let payload = await this.jwtService.verifyAsync(decryptedToken, { + secret: process.env.auth_jwt_secret, + }); + payload.userData = payload; + + // Owner bypasses role/permission allowlists but must still be active in DB + if (isOwnerRole(payload.userData.role)) { + if (this.permissionsService) { + const effective = + await this.permissionsService.getEffectivePermissionsForAdminId( + String(payload.userData._id), + ); + if (effective.size === 0) { + throw new UnauthorizedException('حساب مالک غیرفعال است یا وجود ندارد'); + } + } + request['admin'] = payload; + return true; + } + + if (requiredPermissions?.length) { + if (!this.permissionsService) { + throw new ForbiddenException( + 'سرویس دسترسی‌ها در دسترس نیست', + ); + } + const effective = + await this.permissionsService.getEffectivePermissionsForAdminId( + String(payload.userData._id), + ); + const allowed = requiredPermissions.some((p) => effective.has(p)); + if (!allowed) { + throw new ForbiddenException('دسترسی مجاز نیست: مجوز کافی ندارید'); + } + } else if (requiredRoles?.length) { + if (!this.hasRequiredRole(payload.userData.role, requiredRoles)) { + throw new ForbiddenException('دسترسی مجاز نیست: نقش کافی ندارید'); + } + } + + request['admin'] = payload; + return true; + } catch (err) { + if (err instanceof ForbiddenException) { + throw err; + } + throw new UnauthorizedException(err.message || 'توکن نامعتبر است'); + } + } + + private isLoginPath(request: Request): boolean { + return /\b(login)\b/i.test(request.route?.path || ''); + } + + private isForgetPath(request: Request): boolean { + return /\b(forget|reset)\b/i.test(request.route?.path || ''); + } + + private extractTokenFromHeader(request: Request): string | undefined { + const authorization = request.headers?.authorization || ''; + const [type, token] = authorization.split(' '); + return type === 'Bearer' ? token : undefined; + } + + private hasRequiredRole( + userRole: string | string[], + requiredRoles: string[], + ): boolean { + const roles = Array.isArray(userRole) ? userRole : [userRole]; + return requiredRoles.some((role) => roles.includes(role)); + } +} diff --git a/src/auth/guards/api-key-auth.guard.ts b/src/auth/guards/api-key-auth.guard.ts new file mode 100644 index 0000000..e71ad6a --- /dev/null +++ b/src/auth/guards/api-key-auth.guard.ts @@ -0,0 +1,38 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, ForbiddenException } from '@nestjs/common'; +import { Request } from 'express'; +import { ClientManagementService } from 'src/client-management/client-management.service'; + +@Injectable() +export class ApiKeyAuthGuard implements CanActivate { + constructor(private readonly clientManagementService: ClientManagementService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + // Allow API key to be passed via x-api-key header or as a query parameter for Swagger compatibility + const apiKey = (request.headers['x-api-key'] || request.query['apiKey']) as string; + + if (!apiKey) { + throw new UnauthorizedException('کلید API یافت نشد'); + } + + const client = await this.clientManagementService.findClientByApiKey(apiKey); + + if (!client) { + throw new UnauthorizedException('کلید API نامعتبر است'); + } + + if (client.status !== 'active') { + throw new ForbiddenException('کلاینت غیرفعال است'); + } + + // Attach client information to the request object + request['client'] = { + apiKey: client.apiKey, + name: client.name, + enName: client.enName, + status: client.status, + }; + + return true; + } +} \ No newline at end of file diff --git a/src/auth/guards/ip-rate-limiter.guard.ts b/src/auth/guards/ip-rate-limiter.guard.ts new file mode 100644 index 0000000..118ceff --- /dev/null +++ b/src/auth/guards/ip-rate-limiter.guard.ts @@ -0,0 +1,48 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request, Response } from 'express'; +import { rateLimit } from 'express-rate-limit'; + +@Injectable() +export class IpRateLimiterGuard implements CanActivate { + private readonly limiter: ReturnType; + + constructor(private readonly configService: ConfigService) { + const isProduction = this.configService.get('NODE_ENV') === 'production'; + this.limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: isProduction ? 50 : 100, // Stricter in production + standardHeaders: false, + legacyHeaders: false, + skip: () => + !isProduction && + this.configService.get('DISABLE_RATE_LIMIT') === 'true', // Skip in dev if needed + keyGenerator: (req) => { + // Use IP or user ID if available + return isProduction ? req.ip : ''; // Less strict in development + }, + handler: (req, res) => { + res.status(429).json({ + statusCode: 429, + message: isProduction + ? 'Too many requests' + : 'Dev Mode: Rate limit warning (not enforced)', + }); + }, + }); + } + + async canActivate(context: ExecutionContext): Promise { + const httpContext = context.switchToHttp(); + const req = httpContext.getRequest(); + const res = httpContext.getResponse(); + return new Promise((resolve) => { + this.limiter(req, res, (err?: unknown) => { + if (err) { + return resolve(false); + } + resolve(true); + }); + }); + } +} diff --git a/src/auth/local.strategy.ts b/src/auth/local.strategy.ts new file mode 100644 index 0000000..96ef945 --- /dev/null +++ b/src/auth/local.strategy.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-local'; +import { AuthService } from './auth.service'; + +@Injectable() +export class LocalStrategy extends PassportStrategy(Strategy) { + constructor(private readonly authService: AuthService) { + super(); + } + + // async validate(username: string, password: string): Promise { + // const user = await this.authService.validateUser(username, password); + + // return user; + // } +} diff --git a/src/auth/models/identity.model.ts b/src/auth/models/identity.model.ts new file mode 100644 index 0000000..6e5e65e --- /dev/null +++ b/src/auth/models/identity.model.ts @@ -0,0 +1,14 @@ +import { UserModel } from 'src/database/model/user.model'; + +export class Identity { + user: UserModel; + public get isAuthenticated(): boolean { + return !!this.user; + } +} +// export class AdminIdentity { +// admin: AdminModel; +// public get isAuthenticated(): boolean { +// return !!this.admin; +// } +// } diff --git a/src/business-hours/business-hours.controller.ts b/src/business-hours/business-hours.controller.ts new file mode 100644 index 0000000..2236837 --- /dev/null +++ b/src/business-hours/business-hours.controller.ts @@ -0,0 +1,156 @@ +import { + Controller, + Get, + Post, + Put, + Patch, + Body, + Param, + UseGuards, + HttpStatus, + HttpException, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiTags, + ApiParam, + ApiResponse, + ApiBody, +} from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { AdminModel } from 'src/database/model/admin.model'; +import { BusinessHoursService } from './business-hours.service'; +import { CreateBusinessHoursDto } from './dto/create-business-hours.dto'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { Public } from 'src/auth/auth.decorator'; + +@ApiBearerAuth() +@UseGuards(AdminGuard) +@Permissions(Permission.BusinessHoursManage) +@ApiTags('business-hours') +@Controller('admin/business-hours') +export class BusinessHoursController { + constructor(private readonly businessHoursService: BusinessHoursService) {} + + @Get() + @ApiOperation({ summary: 'Get all business hours configurations' }) + @ApiResponse({ status: 200, description: 'List of all configurations' }) + async getAllConfigs(@AdminIdentity() admin: AdminModel) { + try { + const configs = await this.businessHoursService.getAllConfigs(); + return new BaseResponseDTO( + HttpStatus.OK, + 'Business hours configurations retrieved successfully', + configs, + ); + } catch (error) { + throw new HttpException( + error.message || 'Failed to retrieve configurations', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Get('status') + @Public() + @ApiOperation({ summary: 'Get current business hours status (public)' }) + @ApiResponse({ status: 200, description: 'Current status' }) + async getStatus() { + try { + const status = await this.businessHoursService.isOpen(); + const nextOpen = await this.businessHoursService.getNextOpen(); + const config = await this.businessHoursService.getActiveConfig(); + + return new BaseResponseDTO(HttpStatus.OK, 'Status retrieved successfully', { + open: status.open, + nextOpen: nextOpen?.toISOString() || null, + timezone: config?.timezone || null, + currentWindow: status.currentWindow || null, + }); + } catch (error) { + throw new HttpException( + error.message || 'Failed to get status', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Post() + @ApiOperation({ summary: 'Create a new business hours configuration' }) + @ApiBody({ type: CreateBusinessHoursDto }) + @ApiResponse({ status: 201, description: 'Configuration created' }) + async createConfig( + @Body() createDto: CreateBusinessHoursDto, + @AdminIdentity() admin: AdminModel, + ) { + try { + const config = await this.businessHoursService.createConfig( + createDto as any, + admin.username, + ); + return new BaseResponseDTO( + HttpStatus.CREATED, + 'Business hours configuration created successfully', + config, + ); + } catch (error) { + throw new HttpException( + error.message || 'Failed to create configuration', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Put(':id') + @ApiOperation({ summary: 'Update an existing business hours configuration' }) + @ApiParam({ name: 'id', description: 'Configuration ID' }) + @ApiBody({ type: CreateBusinessHoursDto }) + @ApiResponse({ status: 200, description: 'Configuration updated' }) + async updateConfig( + @Param('id') id: string, + @Body() updateDto: CreateBusinessHoursDto, + @AdminIdentity() admin: AdminModel, + ) { + try { + const config = await this.businessHoursService.updateConfig( + id, + updateDto as any, + admin.username, + ); + return new BaseResponseDTO( + HttpStatus.OK, + 'Business hours configuration updated successfully', + config, + ); + } catch (error) { + throw new HttpException( + error.message || 'Failed to update configuration', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Patch(':id/deactivate') + @ApiOperation({ summary: 'Deactivate a business hours configuration' }) + @ApiParam({ name: 'id', description: 'Configuration ID' }) + @ApiResponse({ status: 200, description: 'Configuration deactivated' }) + async deactivateConfig(@Param('id') id: string) { + try { + await this.businessHoursService.deactivateConfig(id); + return new BaseResponseDTO( + HttpStatus.OK, + 'Business hours configuration deactivated successfully', + null, + ); + } catch (error) { + throw new HttpException( + error.message || 'Failed to deactivate configuration', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/business-hours/business-hours.module.ts b/src/business-hours/business-hours.module.ts new file mode 100644 index 0000000..f9daed9 --- /dev/null +++ b/src/business-hours/business-hours.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { DatabaseModule } from 'src/database/database.module'; +import { + BusinessHoursModel, + BusinessHoursSchema, +} from 'src/database/model/business-hours.model'; +import { BusinessHoursController } from './business-hours.controller'; +import { BusinessHoursService } from './business-hours.service'; +import { StatusController } from './status.controller'; + +@Module({ + imports: [ + DatabaseModule, + MongooseModule.forFeature([ + { name: BusinessHoursModel.name, schema: BusinessHoursSchema }, + ]), + ], + controllers: [BusinessHoursController, StatusController], + providers: [BusinessHoursService], + exports: [BusinessHoursService], +}) +export class BusinessHoursModule {} + diff --git a/src/business-hours/business-hours.service.ts b/src/business-hours/business-hours.service.ts new file mode 100644 index 0000000..782b167 --- /dev/null +++ b/src/business-hours/business-hours.service.ts @@ -0,0 +1,422 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { + BusinessHoursModel, + BusinessHoursConfig, + WeeklyWindow, + Exception, + TimeInterval, +} from 'src/database/model/business-hours.model'; +import { RedisService } from 'src/common/helpers/redis.service'; + +export interface BusinessHoursStatus { + open: boolean; + reason?: string; + nextOpen?: Date; + currentWindow?: TimeInterval; +} + +@Injectable() +export class BusinessHoursService implements OnModuleInit { + private readonly logger = new Logger(BusinessHoursService.name); + private cachedConfig: BusinessHoursConfig | null = null; + private cacheLoadedAt: Date | null = null; + private readonly CACHE_TTL_MS = 60 * 1000; // 1 minute + private readonly REDIS_CHANNEL = 'cfg:business-hours:update'; + + constructor( + @InjectModel(BusinessHoursModel.name) + private readonly businessHoursModel: Model, + private readonly redisService: RedisService, + ) {} + + async onModuleInit() { + // Subscribe to Redis pub/sub for cache invalidation + try { + const client = this.redisService.getClient(); + // Use duplicate client for subscription to avoid blocking + const subClient = client.duplicate(); + await subClient.subscribe(this.REDIS_CHANNEL); + subClient.on('message', (channel, message) => { + if (channel === this.REDIS_CHANNEL) { + this.logger.log('Received cache invalidation signal, clearing cache'); + this.cachedConfig = null; + this.cacheLoadedAt = null; + } + }); + this.logger.log('Subscribed to business hours cache invalidation channel'); + } catch (error) { + this.logger.error('Failed to subscribe to Redis channel', error); + } + } + + /** + * Get the active business hours configuration + */ + async getActiveConfig(): Promise { + // Check cache first + if ( + this.cachedConfig && + this.cacheLoadedAt && + Date.now() - this.cacheLoadedAt.getTime() < this.CACHE_TTL_MS + ) { + return this.cachedConfig; + } + + // Load from DB + const activeConfig = await this.businessHoursModel + .findOne({ isActive: true }) + .lean() + .exec(); + + if (!activeConfig) { + this.logger.warn('No active business hours configuration found'); + return null; + } + + this.cachedConfig = activeConfig.config; + this.cacheLoadedAt = new Date(); + return this.cachedConfig; + } + + /** + * Check if business hours are currently open + */ + async isOpen(now?: Date): Promise { + const config = await this.getActiveConfig(); + if (!config) { + return { + open: false, + reason: 'No business hours configuration found', + }; + } + + // Check global toggle + if (!config.globalToggle) { + return { + open: false, + reason: 'Online conversation is globally disabled', + }; + } + + const checkTime = now || new Date(); + const tzTime = this.convertToTimezone(checkTime, config.timezone); + const dateStr = this.formatDate(tzTime); + const timeStr = this.formatTime(tzTime); + const dayOfWeek = this.getDayOfWeek(tzTime); + + // Check exceptions first + const exception = config.exceptions?.find((e) => e.date === dateStr); + if (exception) { + if (exception.intervals.length === 0) { + const nextOpen = await this.getNextOpen(checkTime); + return { + open: false, + reason: exception.reason || 'Closed due to exception', + nextOpen, + }; + } + // Check if current time is within exception intervals + const isInInterval = this.isTimeInIntervals(timeStr, exception.intervals); + if (isInInterval) { + return { open: true }; + } + const nextOpen = await this.getNextOpen(checkTime); + return { + open: false, + reason: exception.reason || 'Closed due to exception', + nextOpen, + }; + } + + // Check weekly windows + const weeklyWindow = config.weeklyWindows?.find( + (w) => w.day === dayOfWeek, + ); + if (!weeklyWindow || weeklyWindow.intervals.length === 0) { + const nextOpen = await this.getNextOpen(checkTime); + return { + open: false, + reason: 'Online conversation is not available on this day', + nextOpen, + }; + } + + // Check if current time is within any interval + const isInInterval = this.isTimeInIntervals( + timeStr, + weeklyWindow.intervals, + ); + if (isInInterval) { + const currentWindow = this.getCurrentWindow( + timeStr, + weeklyWindow.intervals, + ); + return { open: true, currentWindow }; + } + + const nextOpen = await this.getNextOpen(checkTime); + return { + open: false, + reason: 'Online conversation is not available at this time', + nextOpen, + }; + } + + /** + * Get the next opening time + */ + async getNextOpen(now?: Date): Promise { + const config = await this.getActiveConfig(); + if (!config || !config.globalToggle) { + return null; + } + + const checkTime = now || new Date(); + const tzTime = this.convertToTimezone(checkTime, config.timezone); + const maxDaysToCheck = 14; // Check up to 2 weeks ahead + + for (let dayOffset = 0; dayOffset < maxDaysToCheck; dayOffset++) { + const checkDate = new Date(tzTime); + checkDate.setDate(checkDate.getDate() + dayOffset); + const dateStr = this.formatDate(checkDate); + const dayOfWeek = this.getDayOfWeek(checkDate); + + // Check exceptions first + const exception = config.exceptions?.find((e) => e.date === dateStr); + if (exception) { + if (exception.intervals.length > 0) { + // Find first interval for this exception + const firstInterval = exception.intervals[0]; + const openTime = this.parseTime(firstInterval.start); + checkDate.setHours(openTime.hours, openTime.minutes, 0, 0); + return this.convertFromTimezone(checkDate, config.timezone); + } + continue; // Skip this day if exception closes it + } + + // Check weekly windows + const weeklyWindow = config.weeklyWindows?.find( + (w) => w.day === dayOfWeek, + ); + if (weeklyWindow && weeklyWindow.intervals.length > 0) { + const firstInterval = weeklyWindow.intervals[0]; + const openTime = this.parseTime(firstInterval.start); + checkDate.setHours(openTime.hours, openTime.minutes, 0, 0); + + // If checking today and time has passed, check tomorrow + if (dayOffset === 0 && checkDate <= tzTime) { + continue; + } + + return this.convertFromTimezone(checkDate, config.timezone); + } + } + + return null; + } + + /** + * Get all business hours configurations + */ + async getAllConfigs() { + return this.businessHoursModel.find().sort({ createdAt: -1 }).lean().exec(); + } + + /** + * Create a new business hours configuration + */ + async createConfig( + config: BusinessHoursConfig, + adminUsername: string, + ): Promise { + // Deactivate all existing configs + await this.businessHoursModel.updateMany( + {}, + { $set: { isActive: false } }, + ); + + // Create new active config + const newConfig = new this.businessHoursModel({ + config, + createdBy: [adminUsername], + updatedBy: adminUsername, + isActive: true, + }); + + const saved = await newConfig.save(); + + // Invalidate cache and notify other instances + this.cachedConfig = null; + this.cacheLoadedAt = null; + await this.publishCacheInvalidation(); + + return saved; + } + + /** + * Update an existing business hours configuration + */ + async updateConfig( + id: string, + config: BusinessHoursConfig, + adminUsername: string, + ): Promise { + const existing = await this.businessHoursModel.findById(id); + if (!existing) { + throw new Error('Business hours configuration not found'); + } + + // If activating this config, deactivate all others + if (existing.isActive || config.globalToggle) { + await this.businessHoursModel.updateMany( + { _id: { $ne: id } }, + { $set: { isActive: false } }, + ); + } + + // Update the config + existing.config = config; + existing.updatedBy = adminUsername; + if (!existing.createdBy.includes(adminUsername)) { + existing.createdBy.push(adminUsername); + } + existing.isActive = true; // Activate when updated + + const saved = await existing.save(); + + // Invalidate cache and notify other instances + this.cachedConfig = null; + this.cacheLoadedAt = null; + await this.publishCacheInvalidation(); + + return saved; + } + + /** + * Deactivate a business hours configuration + */ + async deactivateConfig(id: string): Promise { + const existing = await this.businessHoursModel.findById(id); + if (!existing) { + throw new Error('Business hours configuration not found'); + } + + existing.isActive = false; + await existing.save(); + + // Invalidate cache and notify other instances + this.cachedConfig = null; + this.cacheLoadedAt = null; + await this.publishCacheInvalidation(); + } + + /** + * Publish cache invalidation event to Redis + */ + private async publishCacheInvalidation(): Promise { + try { + const client = this.redisService.getClient(); + await client.publish( + this.REDIS_CHANNEL, + JSON.stringify({ timestamp: Date.now() }), + ); + } catch (error) { + this.logger.error('Failed to publish cache invalidation', error); + } + } + + // Helper methods for timezone and time calculations + + private convertToTimezone(date: Date, timezone: string): Date { + // Simple timezone offset calculation + // For production, use a proper timezone library like luxon or date-fns-tz + // This is a simplified version for common timezones + const offsetMap: Record = { + 'Asia/Tehran': 3.5, // UTC+3:30 + 'UTC': 0, + }; + + const offset = offsetMap[timezone] || 0; + const utcTime = date.getTime() + date.getTimezoneOffset() * 60000; + return new Date(utcTime + offset * 3600000); + } + + private convertFromTimezone(date: Date, timezone: string): Date { + const offsetMap: Record = { + 'Asia/Tehran': 3.5, + 'UTC': 0, + }; + + const offset = offsetMap[timezone] || 0; + const utcTime = date.getTime() - offset * 3600000; + return new Date(utcTime - new Date().getTimezoneOffset() * 60000); + } + + private formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + private formatTime(date: Date): string { + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${hours}:${minutes}`; + } + + private getDayOfWeek(date: Date): WeeklyWindow['day'] { + const days: WeeklyWindow['day'][] = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + ]; + return days[date.getDay()]; + } + + private parseTime(timeStr: string): { hours: number; minutes: number } { + const [hours, minutes] = timeStr.split(':').map(Number); + return { hours, minutes }; + } + + private isTimeInIntervals( + timeStr: string, + intervals: TimeInterval[], + ): boolean { + const { hours: h, minutes: m } = this.parseTime(timeStr); + const currentMinutes = h * 60 + m; + + return intervals.some((interval) => { + const start = this.parseTime(interval.start); + const end = this.parseTime(interval.end); + const startMinutes = start.hours * 60 + start.minutes; + const endMinutes = end.hours * 60 + end.minutes; + + return currentMinutes >= startMinutes && currentMinutes < endMinutes; + }); + } + + private getCurrentWindow( + timeStr: string, + intervals: TimeInterval[], + ): TimeInterval | undefined { + const { hours: h, minutes: m } = this.parseTime(timeStr); + const currentMinutes = h * 60 + m; + + return intervals.find((interval) => { + const start = this.parseTime(interval.start); + const end = this.parseTime(interval.end); + const startMinutes = start.hours * 60 + start.minutes; + const endMinutes = end.hours * 60 + end.minutes; + + return currentMinutes >= startMinutes && currentMinutes < endMinutes; + }); + } +} + diff --git a/src/business-hours/dto/create-business-hours.dto.ts b/src/business-hours/dto/create-business-hours.dto.ts new file mode 100644 index 0000000..457eaa4 --- /dev/null +++ b/src/business-hours/dto/create-business-hours.dto.ts @@ -0,0 +1,190 @@ +import { + IsString, + IsBoolean, + IsArray, + ValidateNested, + IsOptional, + IsIn, + Matches, + IsNotEmpty, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +export class TimeIntervalDto { + @ApiProperty({ + required: true, + type: 'string', + description: 'Start time in HH:mm format (24-hour)', + example: '09:00', + pattern: '^([0-1][0-9]|2[0-3]):[0-5][0-9]$', + }) + @IsString() + @Matches(/^([0-1][0-9]|2[0-3]):[0-5][0-9]$/, { + message: 'Time must be in HH:mm format', + }) + start: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'End time in HH:mm format (24-hour)', + example: '19:30', + pattern: '^([0-1][0-9]|2[0-3]):[0-5][0-9]$', + }) + @IsString() + @Matches(/^([0-1][0-9]|2[0-3]):[0-5][0-9]$/, { + message: 'Time must be in HH:mm format', + }) + end: string; +} + +export class WeeklyWindowDto { + @ApiProperty({ + required: true, + type: 'string', + enum: ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + description: 'Day of the week', + example: 'saturday', + }) + @IsString() + @IsIn([ + 'saturday', + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + ]) + day: string; + + @ApiProperty({ + required: true, + type: [TimeIntervalDto], + description: 'Array of time intervals for this day (can have multiple intervals)', + example: [{ start: '09:00', end: '19:30' }], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TimeIntervalDto) + intervals: TimeIntervalDto[]; +} + +export class ExceptionDto { + @ApiProperty({ + required: true, + type: 'string', + description: 'Date in YYYY-MM-DD format for exception (holiday, special day, etc.)', + example: '2025-03-20', + pattern: '^\\d{4}-\\d{2}-\\d{2}$', + }) + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/, { + message: 'Date must be in YYYY-MM-DD format', + }) + date: string; + + @ApiProperty({ + required: true, + type: [TimeIntervalDto], + description: 'Array of time intervals for this exception date. Empty array means closed all day.', + example: [], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TimeIntervalDto) + intervals: TimeIntervalDto[]; + + @ApiProperty({ + required: false, + type: 'string', + description: 'Reason for this exception (e.g., "Holiday Nowruz", "Special hours")', + example: 'Holiday Nowruz', + }) + @IsOptional() + @IsString() + reason?: string; +} + +export class CreateBusinessHoursDto { + @ApiProperty({ + required: true, + type: 'string', + description: 'IANA timezone identifier (e.g., Asia/Tehran, UTC)', + example: 'Asia/Tehran', + }) + @IsString() + @IsNotEmpty() + timezone: string; + + @ApiProperty({ + required: true, + type: 'boolean', + description: 'Master switch to enable/disable online conversations globally', + example: true, + }) + @IsBoolean() + globalToggle: boolean; + + @ApiProperty({ + required: true, + type: [WeeklyWindowDto], + description: 'Weekly schedule with time windows for each day', + example: [ + { day: 'saturday', intervals: [{ start: '09:00', end: '19:30' }] }, + { day: 'sunday', intervals: [{ start: '09:00', end: '19:30' }] }, + { day: 'monday', intervals: [{ start: '09:00', end: '19:30' }] }, + { day: 'tuesday', intervals: [{ start: '09:00', end: '19:30' }] }, + { day: 'wednesday', intervals: [{ start: '08:00', end: '19:00' }] }, + { day: 'thursday', intervals: [{ start: '08:00', end: '12:00' }] }, + { day: 'friday', intervals: [] }, + ], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => WeeklyWindowDto) + weeklyWindows: WeeklyWindowDto[]; + + @ApiProperty({ + required: false, + type: [ExceptionDto], + description: 'Date-specific exceptions (holidays, special hours). Empty array means closed.', + example: [ + { date: '2025-03-20', intervals: [], reason: 'Holiday Nowruz' }, + { date: '2025-03-21', intervals: [{ start: '10:00', end: '14:00' }], reason: 'Special hours' }, + ], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ExceptionDto) + @IsOptional() + exceptions?: ExceptionDto[]; + + @ApiProperty({ + required: false, + description: 'Policy for handling sessions when business hours close', + example: { onClose: 'allow_existing_until_end' }, + type: Object, + }) + @IsOptional() + policy?: { + onClose: 'allow_existing_until_end' | 'hard_close'; + }; + + @ApiProperty({ + required: false, + description: 'Custom message template for closed status', + example: { + key: 'errors.online_conversation_unavailable', + default: 'Online conversation is not available now. Next opening: {{nextOpen}}', + }, + type: Object, + }) + @IsOptional() + messageTemplate?: { + key: string; + default: string; + }; +} + diff --git a/src/business-hours/guards/business-hours.guard.ts b/src/business-hours/guards/business-hours.guard.ts new file mode 100644 index 0000000..d0fb9d2 --- /dev/null +++ b/src/business-hours/guards/business-hours.guard.ts @@ -0,0 +1,44 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { WsException } from '@nestjs/websockets'; +import { Socket } from 'socket.io'; +import { BusinessHoursService } from '../business-hours.service'; +import { createSocketResponse } from 'src/socket/support-management/dto/eventPayloads.dto'; +import { HttpStatus } from '@nestjs/common'; + +@Injectable() +export class BusinessHoursGuard implements CanActivate { + constructor(private readonly businessHoursService: BusinessHoursService) {} + + async canActivate(context: ExecutionContext): Promise { + const client = context.switchToWs().getClient(); + const status = await this.businessHoursService.isOpen(); + const nextOpen = await this.businessHoursService.getNextOpen(); + + if (!status.open) { + const message = + status.reason || + 'Online conversation is not available now. Please try again later.'; + + const errorResponse = createSocketResponse( + 'ONLINE_CONVERSATION_DISABLED', + { + code: 'ONLINE_CONVERSATION_DISABLED', + message, + nextOpen: nextOpen?.toISOString() || null, + }, + message, + HttpStatus.SERVICE_UNAVAILABLE, + ); + + client.emit('response', errorResponse); + throw new WsException({ + code: 'ONLINE_CONVERSATION_DISABLED', + message, + nextOpen: nextOpen?.toISOString() || null, + }); + } + + return true; + } +} + diff --git a/src/business-hours/status.controller.ts b/src/business-hours/status.controller.ts new file mode 100644 index 0000000..32cc2b0 --- /dev/null +++ b/src/business-hours/status.controller.ts @@ -0,0 +1,100 @@ +import { Controller, Get, HttpStatus, HttpException } from '@nestjs/common'; +import { ApiOperation, ApiTags, ApiResponse } from '@nestjs/swagger'; +import { Public } from 'src/auth/auth.decorator'; +import { BusinessHoursService } from './business-hours.service'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { BusinessHoursConfig, WeeklyWindow } from 'src/database/model/business-hours.model'; + +interface WeeklyWindowResponse { + day: string; + dayTranslation: string; + from: string; + to: string; +} + +@Public() +@ApiTags('status') +@Controller('status') +export class StatusController { + private readonly dayTranslations: Record = { + saturday: 'شنبه', + sunday: 'یکشنبه', + monday: 'دوشنبه', + tuesday: 'سه‌شنبه', + wednesday: 'چهارشنبه', + thursday: 'پنج‌شنبه', + friday: 'جمعه', + }; + + constructor(private readonly businessHoursService: BusinessHoursService) {} + + @Get('online-conversation') + @ApiOperation({ summary: 'Get online conversation availability status (public)' }) + @ApiResponse({ status: 200, description: 'Current availability status' }) + async getOnlineConversationStatus() { + try { + const status = await this.businessHoursService.isOpen(); + const nextOpen = await this.businessHoursService.getNextOpen(); + const config = await this.businessHoursService.getActiveConfig(); + + return new BaseResponseDTO(HttpStatus.OK, 'Status retrieved successfully', { + open: status.open, + nextOpen: nextOpen?.toISOString() || null, + timezone: config?.timezone || null, + currentWindow: status.currentWindow || null, + message: status.reason || null, + }); + } catch (error) { + throw new HttpException( + error.message || 'Failed to get status', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Get('working-hours') + @ApiOperation({ summary: 'Get business working hours schedule (public)' }) + @ApiResponse({ status: 200, description: 'Business hours schedule with Persian translations' }) + async getWorkingHours() { + try { + const config = await this.businessHoursService.getActiveConfig(); + + if (!config) { + return new BaseResponseDTO(HttpStatus.OK, 'No business hours configuration found', { + timezone: null, + weeklyWindows: [], + }); + } + + // Transform weeklyWindows to the requested format + // Sort days in order: saturday, sunday, monday, tuesday, wednesday, thursday, friday + const dayOrder = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday']; + const sortedWindows = [...config.weeklyWindows].sort((a, b) => { + return dayOrder.indexOf(a.day) - dayOrder.indexOf(b.day); + }); + + const weeklyWindows: WeeklyWindowResponse[] = sortedWindows.map((window) => { + // Get the first interval (or use null if no intervals - means closed) + const firstInterval = window.intervals.length > 0 ? window.intervals[0] : null; + + return { + day: window.day, + dayTranslation: this.dayTranslations[window.day] || window.day, + from: firstInterval ? firstInterval.start : null, + to: firstInterval ? firstInterval.end : null, + }; + }); + + return new BaseResponseDTO(HttpStatus.OK, 'Business hours retrieved successfully', { + timezone: config.timezone, + weeklyWindows, + }); + } catch (error) { + throw new HttpException( + error.message || 'Failed to get working hours', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} + diff --git a/src/categories/categories.controller.ts b/src/categories/categories.controller.ts new file mode 100644 index 0000000..c763837 --- /dev/null +++ b/src/categories/categories.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { CategoriesService } from './categories.service'; +import { CreateCategoryDto } from './dto/create-category.dto'; + +@ApiBearerAuth() +@UseGuards(AdminGuard) +@Permissions(Permission.CategoriesManage) +@ApiTags('categories module') +@Controller('categories') +export class CategoriesController { + constructor(private readonly categoriesService: CategoriesService) {} + + @Post() + create(@Body() createCategoryDto: CreateCategoryDto) { + return this.categoriesService.create(createCategoryDto); + } + + @Get() + findAll() { + return this.categoriesService.findAll(); + } +} diff --git a/src/categories/categories.module.ts b/src/categories/categories.module.ts new file mode 100644 index 0000000..b197e80 --- /dev/null +++ b/src/categories/categories.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { CategoriesController } from './categories.controller'; +import { CategoriesService } from './categories.service'; + +@Module({ + imports: [DatabaseModule], + controllers: [CategoriesController], + providers: [CategoriesService], +}) +export class CategoriesModule {} diff --git a/src/categories/categories.service.ts b/src/categories/categories.service.ts new file mode 100644 index 0000000..38d5777 --- /dev/null +++ b/src/categories/categories.service.ts @@ -0,0 +1,43 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { CategoriesModel } from 'src/database/model/categories.model'; +import { CreateCategoryDto } from './dto/create-category.dto'; + +@Injectable() +export class CategoriesService { + constructor( + @InjectModel(CategoriesModel.name) + private readonly categoriesModel: Model, + ) {} + async create(createCategoryDto: CreateCategoryDto) { + try { + const category = await this.categoriesModel.findOne({ + title: createCategoryDto.title, + }); + if (category) + throw new HttpException('category_exists', HttpStatus.BAD_REQUEST); + + const newCategory = await this.categoriesModel.create({ + title: createCategoryDto.title, + enTitle: createCategoryDto.enTitle, + icon: createCategoryDto.icon, + }); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', newCategory); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, err.response, null); + } + } + + async findAll() { + try { + const categories = await this.categoriesModel.find(); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', categories); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, err.response, null); + } + } +} diff --git a/src/categories/dto/create-category.dto.ts b/src/categories/dto/create-category.dto.ts new file mode 100644 index 0000000..48d2c16 --- /dev/null +++ b/src/categories/dto/create-category.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateCategoryDto { + @ApiProperty({ + required: true, + type: 'string', + description: 'category of the uploaded dictionary', + example: 'carInsurance', + }) + title: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'category of the uploaded dictionary', + example: 'enTitle', + }) + enTitle: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'name of the icon picture name', + example: 'carInsurance', + }) + icon: string; +} diff --git a/src/categories/dto/update-category.dto.ts b/src/categories/dto/update-category.dto.ts new file mode 100644 index 0000000..d713b9b --- /dev/null +++ b/src/categories/dto/update-category.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateCategoryDto } from './create-category.dto'; + +export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {} diff --git a/src/chat-attachments/attachment-session-expert.guard.ts b/src/chat-attachments/attachment-session-expert.guard.ts new file mode 100644 index 0000000..e9c1a1a --- /dev/null +++ b/src/chat-attachments/attachment-session-expert.guard.ts @@ -0,0 +1,27 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ChatService } from 'src/socket/support-management/chat.service'; + +@Injectable() +export class AttachmentSessionExpertGuard implements CanActivate { + constructor(private readonly chatService: ChatService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const sessionId = req.params?.sessionId as string; + const admin = req.admin; + if (!admin?._id) { + throw new UnauthorizedException('expert_token_required'); + } + await this.chatService.assertParticipantCanUploadVoice( + sessionId, + String(admin._id), + 'Expert', + ); + return true; + } +} diff --git a/src/chat-attachments/attachment-session-user.guard.ts b/src/chat-attachments/attachment-session-user.guard.ts new file mode 100644 index 0000000..c402e40 --- /dev/null +++ b/src/chat-attachments/attachment-session-user.guard.ts @@ -0,0 +1,27 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ChatService } from 'src/socket/support-management/chat.service'; + +@Injectable() +export class AttachmentSessionUserGuard implements CanActivate { + constructor(private readonly chatService: ChatService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const sessionId = req.params?.sessionId as string; + const user = req.user; + if (!user?._id) { + throw new UnauthorizedException('user_token_required'); + } + await this.chatService.assertParticipantCanUploadVoice( + sessionId, + String(user._id), + 'User', + ); + return true; + } +} diff --git a/src/chat-attachments/chat-attachments.controller.ts b/src/chat-attachments/chat-attachments.controller.ts new file mode 100644 index 0000000..355898d --- /dev/null +++ b/src/chat-attachments/chat-attachments.controller.ts @@ -0,0 +1,202 @@ +import { + BadRequestException, + Controller, + Get, + Param, + Post, + Query, + Req, + UploadedFile, + UseGuards, + UseInterceptors, + StreamableFile, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import { tmpdir } from 'node:os'; +import { extname } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import type { Request } from 'express'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity, CurrentIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { AdminDocument } from 'src/database/model/admin.model'; +import { MAX_CHAT_VOICE_BYTES } from 'src/storage/storage.constants'; +import { AttachmentSessionExpertGuard } from './attachment-session-expert.guard'; +import { AttachmentSessionUserGuard } from './attachment-session-user.guard'; +import { ChatAttachmentsService } from './chat-attachments.service'; + +const attachmentDiskStorage = diskStorage({ + destination: (_req, _file, cb) => cb(null, tmpdir()), + filename: (_req, file, cb) => { + const raw = extname(file.originalname || '').toLowerCase().slice(0, 24); + const safe = /^\.[a-z0-9.]{1,20}$/.test(raw) ? raw : ''; + cb(null, `chat-att-${randomUUID()}${safe}`); + }, +}); + +@ApiTags('chat-attachments') +@Controller('chat/attachments') +@ApiBearerAuth() +export class ChatAttachmentsController { + constructor(private readonly attachments: ChatAttachmentsService) {} + + private parseDurationSec(raw: unknown): number | undefined { + if (raw === undefined || raw === null || raw === '') return undefined; + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; + } + + @Post('user/:sessionId') + @ApiOperation({ + summary: + 'Upload chat attachment (voice FFmpeg→m4a, no image, no PDF). Then sendMessage with message=storageKey, presetMessageId, type matching fileType.', + }) + @ApiParam({ name: 'sessionId' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file', 'fileType'], + properties: { + file: { type: 'string', format: 'binary' }, + fileType: { + type: 'string', + enum: ['voice'], //? 'image', 'document' + }, + durationSec: { type: 'number', description: 'Voice: optional duration hint' }, + }, + }, + }) + @UseGuards(AttachmentSessionUserGuard) + @UseInterceptors( + FileInterceptor('file', { + storage: attachmentDiskStorage, + limits: { fileSize: MAX_CHAT_VOICE_BYTES }, + }), + ) + uploadUser( + @Param('sessionId') sessionId: string, + @UploadedFile() file: Express.Multer.File, + @Req() req: Request, + @CurrentIdentity() user: { _id?: unknown }, + ) { + if (!file) throw new BadRequestException('file_required'); + if (!user?._id) throw new BadRequestException('user_token_required'); + const fileType = this.attachments.parseFileType( + (req.body as Record)?.fileType, + ); + return this.attachments.uploadAttachment({ + sessionId, + file, + uploaderId: String(user._id), + uploaderRole: 'User', + fileType, + durationSec: this.parseDurationSec( + (req.body as Record)?.durationSec, + ), + }); + } + + @Post('expert/:sessionId') + @ApiOperation({ summary: 'Upload chat attachment (expert JWT)' }) + @ApiParam({ name: 'sessionId' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file', 'fileType'], + properties: { + file: { type: 'string', format: 'binary' }, + fileType: { type: 'string', enum: ['voice', 'image', 'document'] }, + durationSec: { type: 'number' }, + }, + }, + }) + @UseGuards(AdminGuard, AttachmentSessionExpertGuard) + @Permissions(Permission.AttachmentsExpert) + @UseInterceptors( + FileInterceptor('file', { + storage: attachmentDiskStorage, + limits: { fileSize: MAX_CHAT_VOICE_BYTES }, + }), + ) + uploadExpert( + @Param('sessionId') sessionId: string, + @UploadedFile() file: Express.Multer.File, + @Req() req: Request, + @AdminIdentity() admin: AdminDocument, + ) { + if (!file) throw new BadRequestException('file_required'); + const fileType = this.attachments.parseFileType( + (req.body as Record)?.fileType, + ); + return this.attachments.uploadAttachment({ + sessionId, + file, + uploaderId: String(admin._id), + uploaderRole: 'Expert', + fileType, + durationSec: this.parseDurationSec( + (req.body as Record)?.durationSec, + ), + }); + } + + @Get('user/:sessionId/stream') + @ApiOperation({ + summary: 'Stream a chat attachment (private object). Query: key = full storageKey.', + }) + @ApiParam({ name: 'sessionId' }) + @ApiQuery({ + name: 'key', + required: true, + description: 'Full object key (same as message text for attachment messages)', + example: 'chats/.../voice/....m4a', + }) + @UseGuards(AttachmentSessionUserGuard) + async streamUser( + @Param('sessionId') sessionId: string, + @Query('key') storageKey: string, + ): Promise { + const { stream, contentType } = + await this.attachments.streamPrivateAttachment({ + sessionId, + storageKey, + }); + return new StreamableFile(stream, { + type: contentType, + disposition: `inline; filename="attachment"`, + }); + } + + @Get('expert/:sessionId/stream') + @ApiOperation({ summary: 'Stream attachment (expert JWT)' }) + @ApiParam({ name: 'sessionId' }) + @ApiQuery({ name: 'key', required: true }) + @UseGuards(AdminGuard, AttachmentSessionExpertGuard) + async streamExpert( + @Param('sessionId') sessionId: string, + @Query('key') storageKey: string, + ): Promise { + const { stream, contentType } = + await this.attachments.streamPrivateAttachment({ + sessionId, + storageKey, + }); + return new StreamableFile(stream, { + type: contentType, + disposition: `inline; filename="attachment"`, + }); + } +} diff --git a/src/chat-attachments/chat-attachments.module.ts b/src/chat-attachments/chat-attachments.module.ts new file mode 100644 index 0000000..5581588 --- /dev/null +++ b/src/chat-attachments/chat-attachments.module.ts @@ -0,0 +1,27 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { AudioModule } from 'src/audio/audio.module'; +import { AuthModule } from 'src/auth/auth.module'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { DatabaseModule } from 'src/database/database.module'; +import { SupportManagementModule } from 'src/socket/support-management/support-management.module'; +import { AttachmentSessionExpertGuard } from './attachment-session-expert.guard'; +import { AttachmentSessionUserGuard } from './attachment-session-user.guard'; +import { ChatAttachmentsController } from './chat-attachments.controller'; +import { ChatAttachmentsService } from './chat-attachments.service'; + +@Module({ + imports: [ + DatabaseModule, + AuthModule, + AudioModule, + forwardRef(() => SupportManagementModule), + ], + controllers: [ChatAttachmentsController], + providers: [ + ChatAttachmentsService, + AttachmentSessionUserGuard, + AttachmentSessionExpertGuard, + AdminGuard, + ], +}) +export class ChatAttachmentsModule {} diff --git a/src/chat-attachments/chat-attachments.service.ts b/src/chat-attachments/chat-attachments.service.ts new file mode 100644 index 0000000..d682b7a --- /dev/null +++ b/src/chat-attachments/chat-attachments.service.ts @@ -0,0 +1,330 @@ +import { + BadRequestException, + ForbiddenException, + HttpStatus, + Injectable, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { createReadStream } from 'node:fs'; +import { stat, unlink } from 'node:fs/promises'; +import type { Readable } from 'node:stream'; +import { Model, Types } from 'mongoose'; +import { AudioNormalizationError } from 'src/audio/audio-normalization.errors'; +import { AudioNormalizationService } from 'src/audio/audio-normalization.service'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { ChatMessageAttachmentModel } from 'src/database/model/chat-message-attachment.model'; +import { + NORMALIZED_VOICE_EXT, + NORMALIZED_VOICE_MIME, + allowedMimeForChatFileType, + isVoiceUploadMimeAccepted, + maxBytesForChatFileType, +} from 'src/storage/storage.constants'; +import type { ChatAttachmentFileType } from 'src/storage/storage.types'; +import { + buildChatAttachmentStorageKey, + extensionFromMime, + normalizeExt, +} from 'src/storage/key-builder.helper'; +import { StorageService } from 'src/storage/storage.service'; + +const VOICE_TYPES = new Set(['voice']); +const NON_VOICE_TYPES = new Set(['image', 'document']); + +@Injectable() +export class ChatAttachmentsService { + constructor( + private readonly storage: StorageService, + private readonly audioNormalizer: AudioNormalizationService, + @InjectModel(ChatMessageAttachmentModel.name) + private readonly attachments: Model, + ) {} + + parseFileType(raw: unknown): ChatAttachmentFileType { + const s = String(raw ?? '') + .trim() + .toLowerCase(); + if (s === 'voice' || s === 'image' || s === 'document') return s; + throw new BadRequestException('invalid_fileType_expected_voice_image_or_document'); + } + + async uploadAttachment(params: { + sessionId: string; + uploaderId: string; + uploaderRole: 'User' | 'Expert'; + file: Express.Multer.File; + fileType: ChatAttachmentFileType; + durationSec?: number; + }): Promise { + const { fileType } = params; + if (VOICE_TYPES.has(fileType)) { + return this.handleVoiceUpload(params); + } + if (NON_VOICE_TYPES.has(fileType)) { + return this.handleBinaryAttachmentUpload(params); + } + throw new BadRequestException('invalid_fileType'); + } + + private async handleVoiceUpload(params: { + sessionId: string; + uploaderId: string; + uploaderRole: 'User' | 'Expert'; + file: Express.Multer.File; + durationSec?: number; + }): Promise { + const { sessionId, uploaderId, uploaderRole, file, durationSec } = params; + const inputPath = file.path; + if (!inputPath) { + throw new BadRequestException('attachment_requires_disk_upload'); + } + if (!isVoiceUploadMimeAccepted(file.mimetype)) { + throw new BadRequestException('invalid_audio_mime'); + } + + const maxB = maxBytesForChatFileType('voice'); + if (file.size > maxB) { + await unlink(inputPath).catch(() => {}); + throw new BadRequestException('voice_file_too_large'); + } + + const messageId = new Types.ObjectId(); + const ext = normalizeExt(NORMALIZED_VOICE_EXT); + const storageKey = buildChatAttachmentStorageKey( + sessionId, + messageId.toHexString(), + 'voice', + ext, + ); + + let normalizedPath: string | null = null; + try { + const { outputAbsolutePath, durationSec: probedDuration } = + await this.audioNormalizer.transcodeIncomingVoiceToM4a(inputPath); + normalizedPath = outputAbsolutePath; + + const st = await stat(outputAbsolutePath); + if (!st.size) { + throw new BadRequestException('normalized_voice_empty'); + } + + const stream = createReadStream(outputAbsolutePath); + try { + await this.storage.putPrivateObject({ + key: storageKey, + body: stream, + contentType: NORMALIZED_VOICE_MIME, + contentLength: st.size, + metadata: { + sessionid: sessionId, + messageid: messageId.toHexString(), + normalized: 'aac_mp4', + }, + }); + } finally { + stream.destroy(); + } + + const finalDuration = + probedDuration != null && Number.isFinite(probedDuration) + ? probedDuration + : durationSec != null && !Number.isNaN(Number(durationSec)) + ? Number(durationSec) + : undefined; + + await this.attachments.create({ + messageId, + sessionId: new Types.ObjectId(sessionId), + uploaderId, + uploaderRole, + fileType: 'voice', + storageKey, + bucket: 'private', + originalFilename: file.originalname || `voice${NORMALIZED_VOICE_EXT}`, + mimeType: NORMALIZED_VOICE_MIME, + size: st.size, + ...(finalDuration != null ? { durationSec: finalDuration } : {}), + status: 'ready', + }); + + const presetMessageId = messageId.toHexString(); + return new BaseResponseDTO(HttpStatus.OK, 'voice_uploaded', { + fileType: 'voice' as const, + storageKey, + messageId: presetMessageId, + presetMessageId, + durationSec: finalDuration ?? null, + mimeType: NORMALIZED_VOICE_MIME, + size: st.size, + normalized: true, + originalMimeType: (file.mimetype || '').trim().toLowerCase() || null, + }); + } catch (e: unknown) { + if (e instanceof BadRequestException) throw e; + if (e instanceof AudioNormalizationError) { + const msg = e.message; + if (msg.includes('binary_not_found')) { + throw new ServiceUnavailableException( + 'ffmpeg_not_available_voice_processing_unavailable', + ); + } + this.mapTranscodeFailure(msg); + } + throw e; + } finally { + await unlink(inputPath).catch(() => {}); + if (normalizedPath) { + await unlink(normalizedPath).catch(() => {}); + } + } + } + + private mapTranscodeFailure(message: string): never { + if (message.includes('timeout')) { + throw new BadRequestException('voice_transcode_timeout'); + } + throw new BadRequestException('voice_transcode_failed'); + } + + private async handleImageOrDocumentUpload(params: { + sessionId: string; + uploaderId: string; + uploaderRole: 'User' | 'Expert'; + file: Express.Multer.File; + fileType: 'image' | 'document'; + durationSec?: number; + }): Promise { + void params.durationSec; + const { sessionId, uploaderId, uploaderRole, file, fileType } = params; + const inputPath = file.path; + if (!inputPath) { + throw new BadRequestException('attachment_requires_disk_upload'); + } + + const mime = (file.mimetype || '').trim().toLowerCase(); + if (!allowedMimeForChatFileType(fileType, mime)) { + await unlink(inputPath).catch(() => {}); + throw new BadRequestException(`invalid_mime_for_${fileType}`); + } + + const maxB = maxBytesForChatFileType(fileType); + if (file.size > maxB) { + await unlink(inputPath).catch(() => {}); + throw new BadRequestException(`${fileType}_file_too_large`); + } + + const ext = extensionFromMime(mime); + const messageId = new Types.ObjectId(); + const storageKey = buildChatAttachmentStorageKey( + sessionId, + messageId.toHexString(), + fileType, + ext, + ); + + try { + const stream = createReadStream(inputPath); + try { + await this.storage.putPrivateObject({ + key: storageKey, + body: stream, + contentType: mime, + contentLength: file.size, + metadata: { + sessionid: sessionId, + messageid: messageId.toHexString(), + filetype: fileType, + }, + }); + } finally { + stream.destroy(); + } + + await this.attachments.create({ + messageId, + sessionId: new Types.ObjectId(sessionId), + uploaderId, + uploaderRole, + fileType, + storageKey, + bucket: 'private', + originalFilename: file.originalname || `${fileType}${ext}`, + mimeType: mime, + size: file.size, + status: 'ready', + }); + + const presetMessageId = messageId.toHexString(); + return new BaseResponseDTO(HttpStatus.OK, 'attachment_uploaded', { + fileType, + storageKey, + messageId: presetMessageId, + presetMessageId, + mimeType: mime, + size: file.size, + normalized: false, + }); + } finally { + await unlink(inputPath).catch(() => {}); + } + } + + private async handleBinaryAttachmentUpload(params: { + sessionId: string; + uploaderId: string; + uploaderRole: 'User' | 'Expert'; + file: Express.Multer.File; + fileType: ChatAttachmentFileType; + durationSec?: number; + }): Promise { + const { fileType } = params; + if (fileType !== 'image' && fileType !== 'document') { + throw new BadRequestException('invalid_fileType'); + } + return this.handleImageOrDocumentUpload({ + ...params, + fileType, + }); + } + + /** + * Stream a private attachment after verifying it exists for this session. + */ + async streamPrivateAttachment(params: { + sessionId: string; + storageKey: string; + }): Promise<{ stream: Readable; contentType: string }> { + const { sessionId, storageKey } = params; + const key = (storageKey ?? '').trim(); + if (!key) { + throw new BadRequestException('key_query_required'); + } + const expectedPrefix = `chats/${sessionId}/`; + if (!key.startsWith(expectedPrefix)) { + throw new ForbiddenException('attachment_key_not_in_session'); + } + + const row = await this.attachments + .findOne({ + sessionId: new Types.ObjectId(sessionId), + storageKey: key, + }) + .lean() + .exec(); + + if (!row) { + throw new NotFoundException('attachment_not_found'); + } + + const stream = await this.storage.getObjectReadable({ + kind: 'private', + key, + }); + return { + stream, + contentType: row.mimeType || 'application/octet-stream', + }; + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..8ada9c5 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,127 @@ +import { writeFile } from 'node:fs/promises'; +import { Command } from 'commander'; +import inquirer from 'inquirer'; + +export class CliService { + private readonly program: Command; + constructor() { + this.program = new Command(); + this.setupCommands(); + } + + getServiceParameters() { + return; + } + + private setupCommands() { + this.program + .command('ask') + .description('Ask the user questions based on their role') + .action(async () => { + await this.askWorkerType(); + }); + + this.program.parse(process.argv); + } + private async askWorkerType() { + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'worker', + message: 'What is your role? admin | expert', + }, + ]); + + if (answers.worker === 'admin') { + await this.askAdminQuestions(); + } else if (answers.worker === 'expert') { + await this.askExpertQuestions(); + } else { + console.log('Invalid input. Please enter "admin" or "expert".'); + await this.askWorkerType(); + } + } + + private async askExpertQuestions() { + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'expertMobile', + message: 'What is your Mobile (Expert)?', + }, + { + type: 'input', + name: 'expertEmail', + message: 'What is your email (Expert)?', + validate: (input: string) => { + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + if (emailRegex.test(input)) { + return true; + } + return 'Please enter a valid email address'; + }, + }, + { + type: 'password', + name: 'expertPassword', + message: 'Enter a password (Expert):', + }, + ]); + + writeFile( + `${process.cwd()}/src/static/expert.txt`, + `${answers.expertEmail}-${answers.expertPassword}-${answers.expertMobile}`, + ); + } + + private async askAdminQuestions() { + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'adminMobile', + message: 'What is your Mobile (Admin)?', + }, + { + type: 'input', + name: 'adminEmail', + message: 'What is your email (Admin)?', + validate: (input: string) => { + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + if (emailRegex.test(input)) { + return true; + } + return 'Please enter a valid email address'; + }, + }, + { + type: 'password', + name: 'adminPassword', + message: 'Enter a password (Admin):', + }, + ]); + + writeFile( + `${process.cwd()}/src/static/admin.txt`, + `${answers.adminEmail}-${answers.adminPassword}-${answers.adminMobile}`, + ); + } + + private async askClientQuestions() { + await inquirer.prompt([ + { + type: 'input', + name: 'clientName', + message: 'What is your name (Client)?', + }, + { + type: 'input', + name: 'clientEmail', + message: 'What is your email (Client)?', + }, + ]); + } +} + +(() => { + new CliService(); +})(); diff --git a/src/client-management/client-management.controller.ts b/src/client-management/client-management.controller.ts new file mode 100644 index 0000000..19b3bd1 --- /dev/null +++ b/src/client-management/client-management.controller.ts @@ -0,0 +1,58 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + Query, + } from '@nestjs/common'; + import { + ApiOperation, + ApiBody, + ApiDefaultResponse, + ApiBearerAuth, + ApiParam, + ApiTags, + ApiQuery, + } from '@nestjs/swagger'; + import { AdminGuard } from 'src/auth/guards/admin.guard'; + import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; + import { Permissions } from 'src/common/decorators/permission.decorator'; + import { Permission } from 'src/common/types/permissions.catalog'; + import { AdminModel } from 'src/database/model/admin.model'; + +import { ClientManagementService } from './client-management.service'; +import { CreateClientDto } from './dto/clients.dto'; + + @ApiBearerAuth() + @Permissions(Permission.ClientsManage) + @UseGuards(AdminGuard) + @ApiTags('Client-Management') + @Controller('client') + + export class ClientManagementController { + constructor(private readonly clientManagementService: ClientManagementService) {} + + @ApiBody({type: CreateClientDto }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @UseGuards(AdminGuard) + @Permissions(Permission.ClientsManage) + @Post() + createClient(@Body() createClientDto: CreateClientDto) { + return this.clientManagementService.createClient(createClientDto); + } + + @ApiDefaultResponse({}) + @ApiBearerAuth() + @UseGuards(AdminGuard) + @Permissions(Permission.ClientsManage) + @Get() + getClients() { + return this.clientManagementService.getClients(); + } + } + \ No newline at end of file diff --git a/src/client-management/client-management.module.ts b/src/client-management/client-management.module.ts new file mode 100644 index 0000000..374f52b --- /dev/null +++ b/src/client-management/client-management.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { ClientManagementService } from './client-management.service'; +import { ClientManagementController } from './client-management.controller'; + +@Module({ + imports: [DatabaseModule, DatabaseModule], + controllers: [ClientManagementController], + exports: [ClientManagementService], + providers: [ClientManagementService, DatabaseModule], +}) +export class ClientManagementModule {} diff --git a/src/client-management/client-management.service.ts b/src/client-management/client-management.service.ts new file mode 100644 index 0000000..ffc9478 --- /dev/null +++ b/src/client-management/client-management.service.ts @@ -0,0 +1,54 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { ClientModel } from 'src/database/model/client.model'; +import { Model } from 'mongoose'; +import { CreateClientDto } from './dto/clients.dto'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; + +@Injectable() +export class ClientManagementService { + constructor( + @InjectModel(ClientModel.name) private readonly client: Model, + ) {} + + async createClient(createClientDto:CreateClientDto){ +try { + const client = await this.client.findOne({enName: createClientDto.enName}); + if (client) throw new HttpException('client_exists', HttpStatus.NOT_FOUND); + + const { randomBytes } = await import('crypto'); + const apiKey = randomBytes(32).toString('hex'); + const newClient = await this.client.create({ + name: createClientDto.name, + enName: createClientDto.enName, + apiKey, + status: 'active', + }); + + return new BaseResponseDTO(HttpStatus.CREATED, 'Client created successfully', { + apiKey: newClient.apiKey, + name: newClient.name, + enName: newClient.enName, + }); +} catch (err) { + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, 'Failed to create client', err); +} + } + + async getClients(){ + try { + const clients = await this.client.find({status: 'active'}); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', clients); + } catch (err) { + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, 'Failed to create client', err); + } + } + + async findClientByApiKey(apiKey: string) { + try { + return await this.client.findOne({ apiKey }); + } catch (err) { + throw new HttpException('Failed to find client by API key', HttpStatus.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/client-management/dto/clients.dto.ts b/src/client-management/dto/clients.dto.ts new file mode 100644 index 0000000..79801a7 --- /dev/null +++ b/src/client-management/dto/clients.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class CreateClientDto { + @ApiProperty({ + required: true, + type: 'string', + description: 'persian name of the client', + example: 'بیمه سامان', + }) + name: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'english name of the client', + example: 'Saman-Insurance', + }) + enName: string; + } \ No newline at end of file diff --git a/src/common/decorators/Identity.decorator.ts b/src/common/decorators/Identity.decorator.ts new file mode 100644 index 0000000..c2c5757 --- /dev/null +++ b/src/common/decorators/Identity.decorator.ts @@ -0,0 +1,20 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentIdentity = createParamDecorator( + (data: keyof any, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user; + + // If a specific property of the user is requested (e.g., user ID or role) + // Return just that property; otherwise, return the full user object + return data ? user?.[data] : user; + }, +); +export const AdminIdentity = createParamDecorator( + (data: keyof any, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request['admin']; + + return data ? user?.[data] : user; + }, +); diff --git a/src/common/decorators/permission.decorator.ts b/src/common/decorators/permission.decorator.ts new file mode 100644 index 0000000..ff8a0fe --- /dev/null +++ b/src/common/decorators/permission.decorator.ts @@ -0,0 +1,8 @@ +import { SetMetadata } from '@nestjs/common'; +import { Permission } from 'src/common/types/permissions.catalog'; + +export const PERMISSIONS_KEY = 'permissions'; + +/** Require ANY of the listed permissions (OR). Owner always passes. */ +export const Permissions = (...permissions: Permission[]) => + SetMetadata(PERMISSIONS_KEY, permissions); diff --git a/src/common/decorators/role.decorator.ts b/src/common/decorators/role.decorator.ts new file mode 100644 index 0000000..e038e16 --- /dev/null +++ b/src/common/decorators/role.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/common/decorators/skip-admin-rate-limit.decorator.ts b/src/common/decorators/skip-admin-rate-limit.decorator.ts new file mode 100644 index 0000000..3e6118f --- /dev/null +++ b/src/common/decorators/skip-admin-rate-limit.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const SKIP_ADMIN_RATE_LIMIT_KEY = 'skipAdminRateLimit'; +export const SkipAdminRateLimit = () => SetMetadata(SKIP_ADMIN_RATE_LIMIT_KEY, true); \ No newline at end of file diff --git a/src/common/dto/base-response.dto.ts b/src/common/dto/base-response.dto.ts new file mode 100644 index 0000000..4e68c4d --- /dev/null +++ b/src/common/dto/base-response.dto.ts @@ -0,0 +1,77 @@ +import { HttpStatus } from '@nestjs/common'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Min } from 'class-validator'; + +export class PageOptionsDto { + @ApiPropertyOptional({ + minimum: 1, + default: 1, + }) + @Type(() => Number) + @IsInt() + @Min(1) + @IsOptional() + readonly page?: number = 1; + + @ApiPropertyOptional({ + minimum: 1, + // maximum: 50, + default: 10, + }) + @Type(() => Number) + @IsInt() + @Min(1) + // @Max(50) + @IsOptional() + readonly take?: number = 10; + + get skip(): number { + return (this.page - 1) * this.take; + } +} +export interface PageMetaDtoParameters { + pageOptionsDto: PageOptionsDto; + itemCount: number; +} +export class PageMetaDto { + @ApiProperty() + readonly page: number; + + @ApiProperty() + readonly take: number; + + @ApiProperty() + readonly itemCount: number; + + @ApiProperty() + readonly pageCount: number; + + constructor({ pageOptionsDto, itemCount }: PageMetaDtoParameters) { + this.page = pageOptionsDto.page; + this.take = pageOptionsDto.take; + this.itemCount = itemCount; + this.pageCount = Math.ceil(this.itemCount / this.take); + } +} + +export class BaseResponseDTO { + statusCode: HttpStatus; + message: string; + data: any; + @ApiProperty({ type: () => PageMetaDto }) + readonly meta: PageMetaDto; + + constructor(statusCode: HttpStatus, message, data: any, meta?: PageMetaDto) { + this.statusCode = statusCode; + this.message = message; + this.data = data; + this.meta = meta; + } + mapDataIfArray(mapper: (item: any) => T): T[] { + if (Array.isArray(this.data)) { + return this.data.map(mapper); + } + return { ...this.data }; + } +} diff --git a/src/common/dto/forgetPassword.dto.ts b/src/common/dto/forgetPassword.dto.ts new file mode 100644 index 0000000..0be1683 --- /dev/null +++ b/src/common/dto/forgetPassword.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail } from 'class-validator'; + +export class ForgetPasswordDTO { + @IsEmail() + @ApiProperty() + username: string; +} diff --git a/src/common/dto/login.dto.ts b/src/common/dto/login.dto.ts new file mode 100644 index 0000000..52be13a --- /dev/null +++ b/src/common/dto/login.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, ValidateIf } from 'class-validator'; + +export class UserLoginDTO { + @ApiProperty({ + required: true, + type: 'string', + description: 'mobile of user', + example: '09226187419', + }) + mobile: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'nationalCode of user', + example: '4311402422', + }) + @ValidateIf((o) => o.twoFactor === true) + nationalCode: string | null; + + @ApiProperty({ + required: true, + type: 'string', + default: false, + description: + 'if true , both nationalCode and mobile and if false , just mobile should be sent.', + example: false, + }) + twoFactor: boolean; +} + +export class UserVerifyOtpDTO { + @ApiProperty({ + required: true, + type: 'string', + description: 'mobile of user', + example: '09226187419', + }) + mobile: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'otp send to the mobile of user', + example: '27246', + }) + otp: string; +} + +export class AdminLoginDTO { + @ApiProperty({ + required: true, + type: 'string', + description: 'username of admin', + example: 'admin@chatbot.com', + }) + @IsEmail() + username: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'password of admin', + example: 'p@$$word123321', + }) + password: string; + // @ApiProperty({ examples: Role, required: true, enum: Role }) + // role: Role; +} diff --git a/src/common/dto/resetPassword.dto.ts b/src/common/dto/resetPassword.dto.ts new file mode 100644 index 0000000..6275b5c --- /dev/null +++ b/src/common/dto/resetPassword.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsStrongPassword } from 'class-validator'; + +export class ResetPasswordDTO { + @ApiProperty() + @IsString() + resetToken: string; + + @ApiProperty() + @IsStrongPassword() + newPassword: string; +} diff --git a/src/common/filters/all-exceptions.filter.ts b/src/common/filters/all-exceptions.filter.ts new file mode 100644 index 0000000..0012efa --- /dev/null +++ b/src/common/filters/all-exceptions.filter.ts @@ -0,0 +1,30 @@ +import { Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; +import { BaseExceptionFilter, HttpAdapterHost } from '@nestjs/core'; +import { Request, Response } from 'express'; +import { INestApplication } from '@nestjs/common'; // Keep this import for app.close() + +@Catch() +export class AllExceptionsFilter extends BaseExceptionFilter { + private readonly nestApplication: INestApplication; // Rename to avoid conflict with super constructor + + constructor(httpAdapterHost: HttpAdapterHost, nestApplication: INestApplication) { + super(httpAdapterHost.httpAdapter); + this.nestApplication = nestApplication; + } + + async catch(exception: T, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + + if (exception instanceof HttpException || (exception && typeof exception === 'object' && 'statusCode' in exception && 'message' in exception)) { + super.catch(exception, host); + } else { + const status = HttpStatus.INTERNAL_SERVER_ERROR; + console.error(`CRITICAL ERROR - HTTP Status: ${status} - Path: ${request.url}`); + console.error('Caught by AllExceptionsFilter (forcing exit):', exception); + + await this.nestApplication.close(); + process.exit(1); + } + } +} diff --git a/src/common/filters/ws-all-exceptions.filter.ts b/src/common/filters/ws-all-exceptions.filter.ts new file mode 100644 index 0000000..8b8f782 --- /dev/null +++ b/src/common/filters/ws-all-exceptions.filter.ts @@ -0,0 +1,54 @@ +import { ArgumentsHost, Catch, HttpException, HttpStatus } from '@nestjs/common'; +import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets'; + +@Catch() +export class WsAllExceptionsFilter extends BaseWsExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const client: any = host.switchToWs().getClient(); + + let message = 'Unknown error'; + let status = HttpStatus.BAD_REQUEST; + let details: any = undefined; + + if (exception instanceof HttpException) { + status = exception.getStatus?.() ?? HttpStatus.BAD_REQUEST; + const res: any = exception.getResponse?.(); + if (typeof res === 'string') message = res; + else if (res && typeof res === 'object') { + message = res.message || exception.message; + details = res; + } else { + message = exception.message; + } + } else if (exception instanceof WsException) { + const err = exception.getError(); + if (typeof err === 'string') message = err; + else if (err && typeof err === 'object') { + message = (err as any).message || 'Websocket error'; + details = err; + } else { + message = exception.message; + } + } else if (exception && typeof exception === 'object') { + message = (exception as any).message || message; + } + + try { + client.emit('response', { + event: 'error', + statusCode: status, + message, + data: {}, + meta: details, + }); + } catch { + // ignore emit errors + } + + super.catch(exception, host); + } +} + + + + diff --git a/src/common/helpers/redis.service.ts b/src/common/helpers/redis.service.ts new file mode 100644 index 0000000..3ab9b24 --- /dev/null +++ b/src/common/helpers/redis.service.ts @@ -0,0 +1,66 @@ +// redis.service.ts +import { Injectable } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { Redis } from 'ioredis'; + +@Injectable() +export class RedisService { + constructor(@InjectRedis() private readonly redis: Redis) {} + + //* Returns the raw ioredis client + getClient(): Redis { + return this.redis; + } + + //* Sets a key-value pair with optional expiration (in seconds) + async set(key: string, value: any, ttlSeconds?: number): Promise { + const stringValue = JSON.stringify(value); + if (ttlSeconds) { + await this.redis.set(key, stringValue, 'EX', ttlSeconds); + } else { + await this.redis.set(key, stringValue); + } + } + + //* Gets a parsed JSON value from Redis + async get(key: string): Promise { + const result = await this.redis.get(key); + return result ? JSON.parse(result) : null; + } + + //* Deletes a key + async del(key: string): Promise { + await this.redis.del(key); + } + + //* Push a value to a list (for queue operations) + async enqueue(queueName: string, value: any): Promise { + await this.redis.rpush(queueName, JSON.stringify(value)); + } + + //* Pops a value from the left of a list (FIFO queue) + async dequeue(queueName: string): Promise { + const result = await this.redis.lpop(queueName); + return result ? JSON.parse(result) : null; + } + + //* Returns the queue length + async queueLength(queueName: string): Promise { + return await this.redis.llen(queueName); + } + + // * Gets all values from a queue + async getAllFromQueue(queueName: string): Promise { + const items = await this.redis.lrange(queueName, 0, -1); + return items.map((x) => JSON.parse(x)); + } + async blacklistToken(token: string, ttlInSeconds: number): Promise { + const ttlInMs = ttlInSeconds * 1000; + await this.redis.set(`blacklist:${token}`, 'revoked', 'PX', ttlInMs); + } + + async isTokenBlacklisted(token: string): Promise { + const result = await this.redis.get(`blacklist:${token}`); + return result === 'revoked'; + } +} diff --git a/src/common/helpers/structured-logger.service.ts b/src/common/helpers/structured-logger.service.ts new file mode 100644 index 0000000..8ad1005 --- /dev/null +++ b/src/common/helpers/structured-logger.service.ts @@ -0,0 +1,204 @@ +import { Logger, LoggerService } from '@nestjs/common'; + +/** + * Log categories for better organization + */ +export enum LogCategory { + // Connection lifecycle + CONNECTION = 'CONNECTION', + DISCONNECT = 'DISCONNECT', + + // Room management + ROOM = 'ROOM', + JOIN = 'JOIN', + LEAVE = 'LEAVE', + + // Expert management + EXPERT = 'EXPERT', + EXPERT_ONLINE = 'EXPERT_ONLINE', + EXPERT_OFFLINE = 'EXPERT_OFFLINE', + EXPERT_REASSIGN = 'EXPERT_REASSIGN', + + // User management + USER = 'USER', + USER_QUEUE = 'USER_QUEUE', + + // Messaging + MESSAGE = 'MESSAGE', + MESSAGE_SEND = 'MESSAGE_SEND', + MESSAGE_EDIT = 'MESSAGE_EDIT', + MESSAGE_SEEN = 'MESSAGE_SEEN', + + // Chat lifecycle + CHAT = 'CHAT', + CHAT_START = 'CHAT_START', + CHAT_END = 'CHAT_END', + CHAT_CLOSE = 'CHAT_CLOSE', + CHAT_AUTO_CLOSE = 'CHAT_AUTO_CLOSE', + + // Redis operations + REDIS = 'REDIS', + REDIS_SET = 'REDIS_SET', + REDIS_HASH = 'REDIS_HASH', + REDIS_QUEUE = 'REDIS_QUEUE', + REDIS_REPAIR = 'REDIS_REPAIR', + + // Validation + VALIDATION = 'VALIDATION', + + // Background tasks + BACKGROUND = 'BACKGROUND', + WATCHER = 'WATCHER', + SCHEDULER = 'SCHEDULER', + + // Errors + ERROR = 'ERROR', + + // General + INFO = 'INFO', + DEBUG = 'DEBUG', +} + +/** + * Structured Logger Service + * Provides organized logging with categories and context + */ +export class StructuredLogger implements LoggerService { + private readonly logger: Logger; + private readonly context: string; + + constructor(context: string) { + this.context = context; + this.logger = new Logger(context); + } + + /** + * Format log message with category and context + */ + private formatMessage(category: LogCategory, message: string, context?: Record): string { + const categoryTag = `[${category}]`; + const contextStr = context ? ` ${JSON.stringify(context)}` : ''; + return `${categoryTag} ${message}${contextStr}`; + } + + /** + * Log info message + */ + log(category: LogCategory, message: string, context?: Record): void; + log(message: string, context?: Record): void; + log(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record, context?: Record): void { + if (typeof categoryOrMessage === 'string') { + // Legacy format: just message + this.logger.log(messageOrContext ? `${categoryOrMessage} ${JSON.stringify(messageOrContext)}` : categoryOrMessage); + } else { + // Structured format: category + message + context + const category = categoryOrMessage as LogCategory; + const message = messageOrContext as string; + const ctx = context || {}; + this.logger.log(this.formatMessage(category, message, ctx)); + } + } + + /** + * Log warning message + */ + warn(category: LogCategory, message: string, context?: Record, error?: any): void; + warn(message: string, context?: Record): void; + warn(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record, contextOrError?: Record | any, error?: any): void { + if (typeof categoryOrMessage === 'string') { + // Legacy format + this.logger.warn(messageOrContext ? `${categoryOrMessage} ${JSON.stringify(messageOrContext)}` : categoryOrMessage); + } else { + // Structured format + const category = categoryOrMessage as LogCategory; + const message = messageOrContext as string; + // Check if contextOrError is an error object or context + let ctx: Record = {}; + let err: any = undefined; + if (contextOrError) { + if (contextOrError instanceof Error || (typeof contextOrError === 'object' && contextOrError !== null && 'stack' in contextOrError)) { + err = contextOrError; + } else { + ctx = contextOrError as Record; + } + } + if (error) { + err = error; + } + const formattedMessage = this.formatMessage(category, message, ctx); + if (err) { + this.logger.warn(formattedMessage, err); + } else { + this.logger.warn(formattedMessage); + } + } + } + + /** + * Log error message + */ + error(category: LogCategory, message: string, error?: any, context?: Record): void; + error(message: string, error?: any, context?: Record): void; + error(categoryOrMessage: LogCategory | string, messageOrError?: string | any, errorOrContext?: any, context?: Record): void { + if (typeof categoryOrMessage === 'string') { + // Legacy format + const message = categoryOrMessage; + const error = messageOrError; + const ctx = errorOrContext; + if (error) { + this.logger.error(`${message} ${ctx ? JSON.stringify(ctx) : ''}`, error); + } else { + this.logger.error(message, ctx); + } + } else { + // Structured format + const category = categoryOrMessage as LogCategory; + const message = messageOrError as string; + const error = errorOrContext; + const ctx = context || {}; + const formattedMessage = this.formatMessage(category, message, ctx); + if (error) { + this.logger.error(formattedMessage, error); + } else { + this.logger.error(formattedMessage); + } + } + } + + /** + * Log debug message + */ + debug(category: LogCategory, message: string, context?: Record): void; + debug(message: string, context?: Record): void; + debug(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record, context?: Record): void { + if (typeof categoryOrMessage === 'string') { + // Legacy format + this.logger.debug(messageOrContext ? `${categoryOrMessage} ${JSON.stringify(messageOrContext)}` : categoryOrMessage); + } else { + // Structured format + const category = categoryOrMessage as LogCategory; + const message = messageOrContext as string; + const ctx = context || {}; + this.logger.debug(this.formatMessage(category, message, ctx)); + } + } + + /** + * Log verbose message + */ + verbose(category: LogCategory, message: string, context?: Record): void; + verbose(message: string, context?: Record): void; + verbose(categoryOrMessage: LogCategory | string, messageOrContext?: string | Record, context?: Record): void { + if (typeof categoryOrMessage === 'string') { + // Legacy format + this.logger.verbose(messageOrContext ? `${categoryOrMessage} ${JSON.stringify(messageOrContext)}` : categoryOrMessage); + } else { + // Structured format + const category = categoryOrMessage as LogCategory; + const message = messageOrContext as string; + const ctx = context || {}; + this.logger.verbose(this.formatMessage(category, message, ctx)); + } + } +} + diff --git a/src/common/helpers/versioning.helper.ts b/src/common/helpers/versioning.helper.ts new file mode 100644 index 0000000..13e92e8 --- /dev/null +++ b/src/common/helpers/versioning.helper.ts @@ -0,0 +1,46 @@ +import { AiServiceModule } from 'src/ai-service/ai-service.module'; +import { AiV2Module } from 'src/ai-v2/ai-v2.module'; +import { AdminModule } from 'src/api/admin/admin.module'; +import { UserModule } from 'src/api/user/user.module'; +import { AuthModule } from 'src/auth/auth.module'; +import { AclModule } from 'src/acl/acl.module'; +import { CategoriesModule } from 'src/categories/categories.module'; +import { ClientManagementModule } from 'src/client-management/client-management.module'; +import { ConversationModule } from 'src/conversation/conversation.module'; +import { DatabaseModule } from 'src/database/database.module'; +import { StorageModule } from 'src/storage/storage.module'; +import { DictionariesModule } from 'src/dictionaries/dictionaries.module'; +import { InquiriesModule } from 'src/externals/inquiries/inquiries.module'; +import { SmsModule } from 'src/externals/sms/sms.module'; +import { SsoModule } from 'src/externals/sso/sso.module'; +import { ChatAttachmentsModule } from 'src/chat-attachments/chat-attachments.module'; +import { SupportManagementModule } from 'src/socket/support-management/support-management.module'; +import { UploadModule } from 'src/upload/upload.module'; + +export function getAppImports(): any[] { + const modules = [ + DatabaseModule, + StorageModule, + AuthModule, + AclModule, + AiServiceModule, + AiV2Module, + SmsModule, + SsoModule, + InquiriesModule, + UserModule, + AdminModule, + ClientManagementModule, + UploadModule, + DictionariesModule, + CategoriesModule, + ]; + + if (process.env.SUPPORT_MANAGEMENT === 'true') { + modules.push(SupportManagementModule); + modules.push(ConversationModule); + modules.push(ChatAttachmentsModule); + } + + return modules; +} diff --git a/src/common/middlewares/security-headers.middleware.ts b/src/common/middlewares/security-headers.middleware.ts new file mode 100644 index 0000000..0a00479 --- /dev/null +++ b/src/common/middlewares/security-headers.middleware.ts @@ -0,0 +1,18 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; + +@Injectable() +export class SecurityHeadersMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction) { + // Clickjacking protection + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Content-Security-Policy', "frame-ancestors 'none'"); + + // Other recommended security headers + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-XSS-Protection', '1; mode=block'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + + next(); + } +} diff --git a/src/common/middlewares/swagger-auth.middleware.ts b/src/common/middlewares/swagger-auth.middleware.ts new file mode 100644 index 0000000..a601225 --- /dev/null +++ b/src/common/middlewares/swagger-auth.middleware.ts @@ -0,0 +1,53 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request, Response, NextFunction } from 'express'; + +@Injectable() +export class SwaggerAuthMiddleware implements NestMiddleware { + constructor(private readonly configService: ConfigService) {} + + use(req: Request, res: Response, next: NextFunction) { + // Only protect Swagger endpoints + if (req.path.startsWith('/docs')) { + const swaggerUsername = this.configService.get('SWAGGER_USERNAME'); + const swaggerPassword = this.configService.get('SWAGGER_PASSWORD'); + + // If no credentials are set, allow access (optional protection) + if (!swaggerUsername && !swaggerPassword) { + return next(); + } + + // If only one is set, require both + if (!swaggerUsername || !swaggerPassword) { + return this.sendAuthRequired(res); + } + + // Extract Basic Auth credentials + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Basic ')) { + return this.sendAuthRequired(res); + } + + // Decode Basic Auth + const base64Credentials = authHeader.split(' ')[1]; + const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8'); + const [username, password] = credentials.split(':'); + + // Validate both username and password + if (username === swaggerUsername && password === swaggerPassword) { + return next(); + } + + return this.sendAuthRequired(res); + } + + next(); + } + + private sendAuthRequired(res: Response) { + res.setHeader('WWW-Authenticate', 'Basic realm="Swagger API Documentation"'); + res.status(401).send('Authentication required to access Swagger documentation'); + } +} + diff --git a/src/common/services/audit-log.service.ts b/src/common/services/audit-log.service.ts new file mode 100644 index 0000000..e021190 --- /dev/null +++ b/src/common/services/audit-log.service.ts @@ -0,0 +1,201 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { AuditLogModel } from 'src/database/model/audit-log.model'; +import { Socket } from 'socket.io'; + +export interface AuditLogData { + userId?: string; + action: string; + resource?: string; + resourceId?: string; + oldValues?: Record; + newValues?: Record; + changes?: Record; + ipAddress?: string; + userAgent?: string; + method?: string; + endpoint?: string; + statusCode?: number; + metadata?: Record; +} + +@Injectable() +export class AuditLogService { + constructor( + @InjectModel(AuditLogModel.name) + private readonly auditLogModel: Model, + ) {} + + /** + * Extract IP address and user agent from Socket.io client + */ + private extractClientInfo(client: Socket): { ipAddress?: string; userAgent?: string } { + const ipAddress = + (client.handshake.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || + (client.handshake.headers['x-real-ip'] as string) || + client.handshake.address || + client.conn?.remoteAddress || + undefined; + + const userAgent = + (client.handshake.headers['user-agent'] as string) || undefined; + + return { ipAddress, userAgent }; + } + + /** + * Calculate changes between old and new values + */ + private calculateChanges( + oldValues?: Record, + newValues?: Record, + ): Record | undefined { + if (!oldValues || !newValues) { + return undefined; + } + + const changes: Record = {}; + const allKeys = new Set([...Object.keys(oldValues), ...Object.keys(newValues)]); + + for (const key of allKeys) { + const oldVal = oldValues[key]; + const newVal = newValues[key]; + + // Only include if values actually changed + if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) { + changes[key] = { from: oldVal, to: newVal }; + } + } + + return Object.keys(changes).length > 0 ? changes : undefined; + } + + /** + * Log an audit event + */ + async log(data: AuditLogData, client?: Socket): Promise { + try { + // Extract client info if socket is provided + const clientInfo = client ? this.extractClientInfo(client) : {}; + + // Calculate changes if both old and new values are provided + const changes = data.changes || this.calculateChanges(data.oldValues, data.newValues); + + const auditLog = new this.auditLogModel({ + userId: data.userId ? (data.userId as any) : undefined, + action: data.action, + resource: data.resource, + resourceId: data.resourceId, + oldValues: data.oldValues, + newValues: data.newValues, + changes, + ipAddress: data.ipAddress || clientInfo.ipAddress, + userAgent: data.userAgent || clientInfo.userAgent, + method: data.method, + endpoint: data.endpoint, + statusCode: data.statusCode, + metadata: data.metadata, + createdAt: new Date(), + }); + + await auditLog.save(); + } catch (error) { + // Don't throw errors - audit logging should not break the main flow + console.error('Failed to save audit log:', error); + } + } + + /** + * Log a WebSocket event + */ + async logWebSocketEvent( + action: string, + client: Socket, + options: { + userId?: string; + resource?: string; + resourceId?: string; + oldValues?: Record; + newValues?: Record; + statusCode?: number; + metadata?: Record; + } = {}, + ): Promise { + const clientInfo = this.extractClientInfo(client); + + await this.log( + { + action, + resource: options.resource, + resourceId: options.resourceId, + oldValues: options.oldValues, + newValues: options.newValues, + ipAddress: clientInfo.ipAddress, + userAgent: clientInfo.userAgent, + method: action, // For WebSocket, method is the event name + endpoint: options.resourceId ? `room:${options.resourceId}` : undefined, + statusCode: options.statusCode || 200, + metadata: options.metadata, + userId: options.userId, + }, + client, + ); + } + + /** + * Log an HTTP request (for future use) + */ + async logHttpRequest( + action: string, + req: any, + options: { + userId?: string; + resource?: string; + resourceId?: string; + oldValues?: Record; + newValues?: Record; + statusCode?: number; + metadata?: Record; + } = {}, + ): Promise { + const ipAddress = + req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.headers['x-real-ip'] || + req.ip || + req.connection?.remoteAddress || + undefined; + + const userAgent = req.headers['user-agent'] || undefined; + + await this.log({ + action, + resource: options.resource, + resourceId: options.resourceId, + oldValues: options.oldValues, + newValues: options.newValues, + ipAddress, + userAgent, + method: req.method, + endpoint: req.path || req.url, + statusCode: options.statusCode || 200, + metadata: options.metadata, + userId: options.userId, + }); + } +} + + + + + + + + + + + + + + + diff --git a/src/common/tools/encryption-helper.ts b/src/common/tools/encryption-helper.ts new file mode 100644 index 0000000..e1fcbfe --- /dev/null +++ b/src/common/tools/encryption-helper.ts @@ -0,0 +1,39 @@ +import * as crypto from 'node:crypto'; + +export class EncryptionHelper { + private static enc_algorithm = 'aes-256-cbc'; + private static enc_key = Buffer.from( + '8l4VcgZ7b2pysZznW025123kjhUIOHGF', + 'utf8', + ); + private static enc_iv = Buffer.from('-y/B?D(R+R;lQeTh', 'utf8'); + + public static initialize(algorithm: string, key: string, iv: string): void { + EncryptionHelper.enc_algorithm = algorithm; + EncryptionHelper.enc_key = Buffer.from(key, 'utf8'); + EncryptionHelper.enc_iv = Buffer.from(iv, 'utf8'); + } + + public static encrypt(text): string { + const cipher = crypto.createCipheriv( + this.enc_algorithm, + Buffer.from(this.enc_key), + this.enc_iv, + ); + let encrypted = cipher.update(text); + encrypted = Buffer.concat([encrypted, cipher.final()]); + return encrypted.toString('hex'); + } + + public static decrypt(text): string { + const encryptedText = Buffer.from(text, 'hex'); + const decipher = crypto.createDecipheriv( + this.enc_algorithm, + Buffer.from(this.enc_key), + this.enc_iv, + ); + let decrypted = decipher.update(encryptedText); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted.toString(); + } +} diff --git a/src/common/tools/time-helper.ts b/src/common/tools/time-helper.ts new file mode 100644 index 0000000..09e1ae5 --- /dev/null +++ b/src/common/tools/time-helper.ts @@ -0,0 +1,95 @@ +import * as jalaliMoment from 'moment-jalaali'; + +export class TimeHelper { + public static unix2Date(unix, time = false) { + let date = new Date(unix * 1000); + if (time) + return `${(date.getHours() + '').padStart(2, '0')}:${(date.getMinutes() + '').padStart(2, '0')}`; + return date; + } + public static isValid(date, type = 'miladi') { + if (type === 'miladi') { + const valid = Date.parse(date); + return !isNaN(valid) && valid > 0; + } + return true; + } + public static miladi2jalali(date, format = 'jYYYY-jMM-jDD') { + if (!this.isValid(date)) return date; + + date = jalaliMoment(date); + return date.format(format); + } + + public static unix2PersianTimeAndDate(date) { + date = this.unix2Date(date); + + // Get time in HH:mm format + let time = `${(date.getHours() + '').padStart(2, '0')}:${(date.getMinutes() + '').padStart(2, '0')}`; + + // Convert to Jalali date with double-digit months and days + let jalaliDate = this.miladi2jalali(date, 'jYYYY-jMM-jDD'); + + return [time, jalaliDate]; + } + + public static iso2PersianTimeAndDate(isoDate: string | Date): [string, string] { + const date = typeof isoDate === 'string' ? new Date(isoDate) : isoDate; + + // Get time in HH:mm format + let time = `${(date.getHours() + '').padStart(2, '0')}:${(date.getMinutes() + '').padStart(2, '0')}`; + + // Convert to Jalali date with double-digit months and days + let jalaliDate = this.miladi2jalali(date, 'jYYYY-jMM-jDD'); + + return [time, jalaliDate]; + } + + public static readonly normalizeDate = (date: string) => { + const [year, month, day] = date.split('/'); + return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; + }; + + public static jalaliToISO(jalaliDate: string): string { + // Convert Jalali date to Gregorian + const [year, month, day] = jalaliDate.split('/'); + const gregorianDate = jalaliMoment( + `${year}/${month}/${day}`, + 'jYYYY/jMM/jDD', + ).format('YYYY-MM-DD'); + + // Create a new date object with the converted date + const date = new Date(gregorianDate); + + // Format to ISO string + return date.toISOString(); + } + + public static divideDateRange( + startDate: string, + endDate: string, + ): { start: string; end: string }[] { + // Convert dates to moment objects + const start = jalaliMoment(startDate, 'jYYYY/jMM/jDD'); + const end = jalaliMoment(endDate, 'jYYYY/jMM/jDD'); + + // Calculate total days + const totalDays = end.diff(start, 'days'); + const segmentDays = Math.floor(totalDays / 5); + + const segments: { start: string; end: string }[] = []; + let currentStart = start.clone(); + + for (let i = 0; i < 5; i++) { + const segmentEnd = + i === 4 ? end : currentStart.clone().add(segmentDays, 'days'); + segments.push({ + start: currentStart.format('jYYYY/jMM/jDD'), + end: segmentEnd.format('jYYYY/jMM/jDD'), + }); + currentStart = segmentEnd.clone().add(1, 'days'); + } + + return segments; + } +} diff --git a/src/common/types/categories.type.ts b/src/common/types/categories.type.ts new file mode 100644 index 0000000..7546e23 --- /dev/null +++ b/src/common/types/categories.type.ts @@ -0,0 +1,12 @@ +export enum CategoriesEnum { + carInsurance = 'carInsurance', + liabilityInsurance = 'liabilityInsurance', + healthInsurance = 'healthInsurance', + lifeInsurance = 'lifeInsurance', + motorcycleInsurance = 'motorcycleInsurance', + fireInsurance = 'fireInsurance', + travelInsurance = 'travelInsurance', + transportInsurance = 'transportInsurance', + incidentsInsurance = 'incidentsInsurance', + general = 'general', +} diff --git a/src/common/types/expert-prepared-message-category.type.ts b/src/common/types/expert-prepared-message-category.type.ts new file mode 100644 index 0000000..83e6972 --- /dev/null +++ b/src/common/types/expert-prepared-message-category.type.ts @@ -0,0 +1,24 @@ +export enum ExpertPreparedMessageCategory { + Greetings = 'greetings', + QuickAnswers = 'quick_answers', + Closing = 'closing', + FollowUp = 'follow_up', + Apology = 'apology', + Other = 'other', +} + +export const EXPERT_PREPARED_MESSAGE_CATEGORIES = Object.values( + ExpertPreparedMessageCategory, +); + +export const EXPERT_PREPARED_MESSAGE_CATEGORY_LABELS: Record< + ExpertPreparedMessageCategory, + string +> = { + [ExpertPreparedMessageCategory.Greetings]: 'Greetings', + [ExpertPreparedMessageCategory.QuickAnswers]: 'Quick answers', + [ExpertPreparedMessageCategory.Closing]: 'Closing', + [ExpertPreparedMessageCategory.FollowUp]: 'Follow-up', + [ExpertPreparedMessageCategory.Apology]: 'Apology', + [ExpertPreparedMessageCategory.Other]: 'Other', +}; diff --git a/src/common/types/expert.type.ts b/src/common/types/expert.type.ts new file mode 100644 index 0000000..51476ce --- /dev/null +++ b/src/common/types/expert.type.ts @@ -0,0 +1,4 @@ +export interface ExpertData { + activeSessions: string; + maxSessions: string; +} diff --git a/src/common/types/func.type.ts b/src/common/types/func.type.ts new file mode 100644 index 0000000..4dfbfc0 --- /dev/null +++ b/src/common/types/func.type.ts @@ -0,0 +1,6 @@ +export enum funcsEnum { + create = 'create', + update = 'update', + replace = 'replace', + } + \ No newline at end of file diff --git a/src/common/types/permissions.catalog.ts b/src/common/types/permissions.catalog.ts new file mode 100644 index 0000000..3f4e601 --- /dev/null +++ b/src/common/types/permissions.catalog.ts @@ -0,0 +1,153 @@ +import { BadRequestException } from '@nestjs/common'; +import { Role } from './role.type'; + +/** + * Code-defined permission catalog (ADR 0001). + * Hybrid: coarse modules + fine keys where defaults differ. + */ +export enum Permission { + ProfileRead = 'profile.read', + ProfileWrite = 'profile.write', + + StaffCreateExpert = 'staff.create_expert', + StaffCreateSupervisor = 'staff.create_supervisor', + StaffCreateAdmin = 'staff.create_admin', + + DashboardView = 'dashboard.view', + + ConversationsOwn = 'conversations.own', + ConversationsAll = 'conversations.all', + ConversationsExport = 'conversations.export', + ConversationsRate = 'conversations.rate', + + UsersList = 'users.list', + UsersExport = 'users.export', + ExpertsList = 'experts.list', + ExpertsReport = 'experts.report', + + ReportsView = 'reports.view', + ReportsExpertTiming = 'reports.expert_timing', + + BusinessHoursManage = 'business_hours.manage', + ClientsManage = 'clients.manage', + CategoriesManage = 'categories.manage', + + PreparedMessagesManage = 'prepared_messages.manage', + PreparedMessagesRead = 'prepared_messages.read', + + DictionariesRead = 'dictionaries.read', + DictionariesWrite = 'dictionaries.write', + DictionariesCreate = 'dictionaries.create', + DictionariesApprove = 'dictionaries.approve', + DictionariesAssets = 'dictionaries.assets', + /** Admin-style direct question edit without modification request. */ + DictionariesDirectEdit = 'dictionaries.direct_edit', + + AttachmentsExpert = 'attachments.expert', +} + +export const ALL_ASSIGNABLE_PERMISSIONS: readonly Permission[] = + Object.values(Permission); + +/** Permissions that may never appear on role templates or overrides. */ +export const OWNER_ONLY_META = { + RolesManage: 'acl.roles.manage', + OverridesManage: 'acl.overrides.manage', +} as const; + +export function isAssignablePermission(key: string): key is Permission { + return (ALL_ASSIGNABLE_PERMISSIONS as readonly string[]).includes(key); +} + +export function assertAssignablePermissions(keys: string[]): Permission[] { + const invalid = keys.filter((k) => !isAssignablePermission(k)); + if (invalid.length) { + throw new BadRequestException( + `Unknown or non-assignable permissions: ${invalid.join(', ')}`, + ); + } + return keys as Permission[]; +} + +const ADMIN_DEFAULT: Permission[] = [ + Permission.ProfileRead, + Permission.ProfileWrite, + Permission.StaffCreateExpert, + Permission.DashboardView, + Permission.ConversationsAll, + Permission.ConversationsExport, + Permission.ConversationsRate, + Permission.UsersList, + Permission.UsersExport, + Permission.ExpertsList, + Permission.ExpertsReport, + Permission.ReportsView, + Permission.ReportsExpertTiming, + Permission.BusinessHoursManage, + Permission.ClientsManage, + Permission.CategoriesManage, + Permission.PreparedMessagesManage, + Permission.DictionariesRead, + Permission.DictionariesWrite, + Permission.DictionariesCreate, + Permission.DictionariesApprove, + Permission.DictionariesAssets, + Permission.DictionariesDirectEdit, +]; + +const SUPERVISOR_DEFAULT: Permission[] = [ + Permission.ProfileRead, + Permission.ProfileWrite, + Permission.StaffCreateExpert, + Permission.DashboardView, + Permission.ConversationsAll, + Permission.ConversationsExport, + Permission.ConversationsRate, + Permission.UsersList, + Permission.UsersExport, + Permission.ExpertsList, + Permission.ExpertsReport, + Permission.ReportsView, + Permission.ReportsExpertTiming, + Permission.BusinessHoursManage, + Permission.ClientsManage, + Permission.CategoriesManage, + Permission.PreparedMessagesManage, +]; + +const EXPERT_DEFAULT: Permission[] = [ + Permission.ProfileRead, + Permission.ProfileWrite, + Permission.ConversationsOwn, + Permission.UsersList, + Permission.UsersExport, + Permission.ReportsExpertTiming, + Permission.PreparedMessagesRead, + Permission.DictionariesRead, + Permission.DictionariesWrite, + Permission.AttachmentsExpert, +]; + +export const DEFAULT_ROLE_PERMISSIONS: Record< + Role.Admin | Role.Supervisor | Role.Expert, + Permission[] +> = { + [Role.Admin]: ADMIN_DEFAULT, + [Role.Supervisor]: SUPERVISOR_DEFAULT, + [Role.Expert]: EXPERT_DEFAULT, +}; + +export function createPermissionForRole( + role: string, +): Permission | null { + switch (role) { + case Role.Expert: + return Permission.StaffCreateExpert; + case Role.Supervisor: + return Permission.StaffCreateSupervisor; + case Role.Admin: + return Permission.StaffCreateAdmin; + default: + return null; + } +} diff --git a/src/common/types/react.type.ts b/src/common/types/react.type.ts new file mode 100644 index 0000000..05c2469 --- /dev/null +++ b/src/common/types/react.type.ts @@ -0,0 +1,5 @@ +export enum ReactEnum { + like = 'Like', + dislike = 'Dislike', + nothing = 'Nothing', +} diff --git a/src/common/types/role.type.ts b/src/common/types/role.type.ts new file mode 100644 index 0000000..b9dd4ae --- /dev/null +++ b/src/common/types/role.type.ts @@ -0,0 +1,22 @@ +export enum Role { + User = 'user', + Admin = 'admin', + Expert = 'expert', + Supervisor = 'supervisor', + Owner = 'owner', +} + +/** System staff roles that cannot be deleted/renamed. */ +export const SYSTEM_STAFF_ROLES: readonly Role[] = [ + Role.Admin, + Role.Expert, + Role.Supervisor, +] as const; + +export function isOwnerRole(role: string | Role | undefined | null): boolean { + return role === Role.Owner; +} + +export function isSystemStaffRole(role: string): boolean { + return (SYSTEM_STAFF_ROLES as readonly string[]).includes(role); +} diff --git a/src/common/types/sender.type.ts b/src/common/types/sender.type.ts new file mode 100644 index 0000000..8b0bdd1 --- /dev/null +++ b/src/common/types/sender.type.ts @@ -0,0 +1,5 @@ +export enum Sender { + User = 'User', + Bot = 'Bot', + Expert = 'Expert', +} diff --git a/src/conversation/conversation.controller.ts b/src/conversation/conversation.controller.ts new file mode 100644 index 0000000..98ea1ce --- /dev/null +++ b/src/conversation/conversation.controller.ts @@ -0,0 +1,241 @@ +import { + Controller, + Get, + Post, + Body, + Param, + UseGuards, + Query, + Res, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { Response } from 'express'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { CurrentIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { ConversationService } from './conversation.service'; +import { AdminRatesBot, AdminRatesExpert } from './dto/admin.dto'; + +@ApiBearerAuth() +@UseGuards(AdminGuard) +@ApiTags('conversation') +@Controller('conversation') +export class ConversationController { + constructor(private readonly conversationService: ConversationService) {} + + @Permissions(Permission.ConversationsOwn) + @ApiOperation({ + summary: + 'Chat histories in expert panel (Expert sees his own conversations list) + filters(user mobile,date)', + }) + @ApiQuery({ + name: 'user', + required: false, + type: String, + description: 'Users mobile filter', + example: '09226187419', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1403/07/01-1403/11/07")', + example: '1403/07/01-1403/11/07', + }) + @Get('/expert') + findChatHistory( + @CurrentIdentity() currentUser: any, + @Query('user') user?: string, + @Query('date') date?: string, + ) { + return this.conversationService.expertSessions({ user, date }, currentUser); + } + + @Permissions(Permission.ConversationsOwn) + @ApiOperation({ + summary: + 'Expert can start a conversation with a user and calls this api to have a start point.', + }) + @Get('/expert/start/:sessionId') + startConversationApi(@Param('sessionId') sessionId?: string) { + return this.conversationService.startConversation(sessionId); + } + + @Permissions(Permission.ConversationsOwn) + @ApiOperation({ + summary: 'Single chat history', + }) + @ApiParam({ name: 'sessionId' }) + @Get('/expert/:sessionId') + findSignleChatHistory(@Param('sessionId') sessionId?: string) { + return this.conversationService.singleChatExpert(sessionId); + } + + + @Permissions(Permission.ConversationsAll) + @ApiOperation({ + summary: + 'Chat histories in expert panel (Expert sees his own conversations list) + filters(user mobile,date)', + }) + @ApiQuery({ + name: 'user', + required: false, + type: String, + description: 'Users mobile filter', + example: '09226187419', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1403/07/01-1403/11/07")', + example: '1403/07/01-1403/11/07', + }) + @ApiQuery({ + name: 'expert', + required: false, + type: String, + description: "Filter by the expert's email OR their name/family name", + example: 'expert@chatbot.com', + }) + @ApiQuery({ + name: 'status', + required: false, + enum: ["all", "bot", "online"], + description: 'Filter sessions by expert status: all, bot-handled, or online-expert-handled', + example: 'all', + }) + @ApiQuery({ + name: 'page', + required: false, + type: Number, + description: 'Page number (starts from 1)', + example: 1, + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Number of items per page', + example: 50, + }) + @Get('/admin') + chatHistoryAdmin( + @Query('user') user?: string, + @Query('date') date?: string, + @Query('expert') expert?: string, + @Query('status') status?: "all" | "bot" | "online", + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + const pageNum = page ? parseInt(page, 10) : 1; + const limitNum = limit ? parseInt(limit, 10) : 50; + + return this.conversationService.expertSessionsByAdmin({ + user, + date, + expert, + status, + page: pageNum, + limit: limitNum, + }); + } + + @Permissions(Permission.ConversationsExport) + @ApiOperation({ + summary: 'Export chat history to Excel with all messages and details', + description: + 'Exports all chat sessions matching the filters to an Excel file with two sheets: ' + + '1) Sessions Overview (with user info, expert info, ratings) ' + + '2) Messages Detail (all messages with sender info)', + }) + @ApiQuery({ + name: 'user', + required: false, + type: String, + description: 'Filter by user mobile number or name', + example: '09226187419', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1403/07/01-1403/11/07")', + example: '1403/07/01-1403/11/07', + }) + @ApiQuery({ + name: 'expert', + required: false, + type: String, + description: "Filter by the expert's email OR their name/family name", + example: 'expert@chatbot.com', + }) + @ApiQuery({ + name: 'status', + required: false, + enum: ["all", "bot", "online"], + description: 'Filter sessions by expert status: all, bot-handled, or online-expert-handled', + example: 'all', + }) + @Get('/admin/export-excel') + async exportChatHistory( + @Query('user') user?: string, + @Query('date') date?: string, + @Query('expert') expert?: string, + @Query('status') status?: "all" | "bot" | "online", + @Res() res?: Response, + ) { + const buffer = await this.conversationService.exportChatHistoryToExcel({ + user, + date, + expert, + status, + }); + + const filename = `chat-history-${new Date().getTime()}.xlsx`; + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); + } + + @Permissions(Permission.ConversationsAll) + @ApiOperation({ + summary: 'Single chat history admin', + }) + @ApiParam({ name: 'sessionId' }) + @Get('/admin/:sessionId') + findSignleChatHistoryAdmin(@Param('sessionId') sessionId?: string) { + return this.conversationService.singleChatExpert(sessionId); + } + + @Permissions(Permission.ConversationsRate) + @ApiOperation({ + summary: 'Single chat history admin', + }) + @Post('/admin/rateExpert') + adminRateExpert(@Body() body: AdminRatesExpert) { + return this.conversationService.adminRateExpert(body); + } + + @Permissions(Permission.ConversationsRate) + @ApiOperation({ + summary: 'Single chat history admin', + }) + @Post('/admin/rateBot') + adminRateBot(@Body() body: AdminRatesBot) { + return this.conversationService.adminRateBot(body); + } +} diff --git a/src/conversation/conversation.module.ts b/src/conversation/conversation.module.ts new file mode 100644 index 0000000..a9b4be8 --- /dev/null +++ b/src/conversation/conversation.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { ConversationController } from './conversation.controller'; +import { ConversationService } from './conversation.service'; + +@Module({ + imports: [DatabaseModule], + controllers: [ConversationController], + providers: [ConversationService], + exports: [ConversationService], +}) +export class ConversationModule {} diff --git a/src/conversation/conversation.service.ts b/src/conversation/conversation.service.ts new file mode 100644 index 0000000..5d2df7c --- /dev/null +++ b/src/conversation/conversation.service.ts @@ -0,0 +1,643 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { BaseResponseDTO, PageMetaDto, PageOptionsDto } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { Role } from 'src/common/types/role.type'; +import { AdminModel } from 'src/database/model/admin.model'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { UserModel } from 'src/database/model/user.model'; +import { AdminRatesExpert } from './dto/admin.dto'; +import * as ExcelJS from 'exceljs'; + +@Injectable() +export class ConversationService { + constructor( + @InjectModel(UserModel.name) private readonly user: Model, + @InjectModel(SessionModel.name) + private readonly session: Model, + @InjectModel(AdminModel.name) private readonly admin: Model, + ) {} + async expertSessions(filters, currentUser) { + try { + const query: any = { + connectedToExpert: true, + expert: currentUser.username, + }; + + // Date filter + if (filters.date) { + const [startDate, endDate] = filters.date.split('-'); + + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + + // Create date objects + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + + // Set start to beginning of day and end to end of day + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + query.createdISO = { + $gte: startDateObj, + $lte: endDateObj, + }; + } + + // User mobile filter + if (filters.user) { + const usersWithMobile = await this.user.find({ mobile: filters.user }); + const userIds = usersWithMobile.map((user) => user._id); + query.userId = { $in: userIds }; + } + + const sessions = await this.session.find(query); + + const structuredData = sessions.map((session) => ({ + _id: session._id, + chatTitle: session.chatTitle, + subTitle: session.messages[1]?.text || '', + expert: session.expert, + createdAt: session.createdAt, + createdISO: session.createdISO, + })); + + return new BaseResponseDTO(HttpStatus.OK, 'success', structuredData); + } catch (err) { + console.error('Error in expertSessions:', err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async startConversation( + sessionId: string, + forEnd: boolean = false, + ): Promise { + try { + if (!sessionId) { + throw new HttpException('session_id_required', HttpStatus.BAD_REQUEST); + } + const updateField = forEnd ? 'onlineEndDate' : 'onlineStartDate'; + const updateData = forEnd + ? { + [updateField]: new Date(), + onlineChatClosed: true, + chatClosed: true, + } + : { + [updateField]: new Date(), + }; + + const updatedSession = await this.session.findOneAndUpdate( + { _id: new Types.ObjectId(sessionId) }, + { $set: updateData }, + { new: true }, + ); + + if (!updatedSession) { + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + } + + return new BaseResponseDTO( + HttpStatus.OK, + forEnd ? 'conversation_ended' : 'conversation_started', + { + sessionId: updatedSession._id, + [updateField]: updatedSession[updateField], + }, + ); + } catch (error) { + console.error('Start Conversation Error:', error); + + if (error instanceof HttpException) { + throw new BaseResponseDTO(error.getStatus(), error.message, null); + } + + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'error_updating_conversation', + null, + ); + } + } + + async singleChatExpert(sessionId) { + try { + const session = await this.session.findOne({ _id: sessionId }); + if (!session) + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + const user = await this.user.findOne({ _id: session.userId }); + console.log(user); + return new BaseResponseDTO(HttpStatus.OK, 'success', { + _id: session._id, + user: user.mobile, + name: user.name, + family: user.family, + chatTitle: session.chatTitle, + expert: session.expert, + expertRate: session.expertRate, + adminRateToExpert: session.adminRateToExpert, + adminRateToBot: session.adminRateToBot, + messages: session.messages, + }); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async expertSessionsByAdmin(filters: { + user?: string; + date?: string; + expert?: string; + status?: "all" | "bot" | "online"; + page?: number; + limit?: number; + }) { + try { + const query: any = {}; + + // Status filter - apply at database level for efficiency + if (filters.status === 'bot') { + // Bot-handled sessions: NOT connected to expert + query.connectedToExpert = false; + } else if (filters.status === 'online') { + // Expert-handled sessions: connected to expert + query.connectedToExpert = true; + } + // If status is 'all' or not provided, don't add connectedToExpert filter + + // Date filter + if (filters.date) { + const dateParts = filters.date.split('-'); + + if (dateParts.length < 2 || !dateParts[0] || !dateParts[1]) { + throw new HttpException( + 'Invalid date range format. Expected "YYYY/MM/DD-YYYY/MM/DD".', + HttpStatus.BAD_REQUEST + ); + } + + const [startDate, endDate] = dateParts; + + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + query.createdISO = { + $gte: startDateObj, + $lte: endDateObj, + }; + } + + // User mobile filter + if (filters.user) { + const userIdentifier = filters.user.trim(); + const isNameSearch = /\D/.test(userIdentifier); + + if (isNameSearch) { + const normalizedName = userIdentifier.replace(/ي/g, 'ی').replace(/ك/g, 'ک'); + const searchRegex = new RegExp(normalizedName, 'i'); + + const usersFound = await this.user.find({ + $or: [ + { name: { $regex: searchRegex } }, + { family: { $regex: searchRegex } }, + ], + }); + + if (usersFound.length > 0) { + const userIds = usersFound.map((user) => user._id); + query.userId = { $in: userIds }; + } else { + // Return empty result with pagination metadata + const pageOptions = Object.assign(new PageOptionsDto(), { + page: filters.page || 1, + take: filters.limit || 50, + }); + const meta = new PageMetaDto({ pageOptionsDto: pageOptions, itemCount: 0 }); + return new BaseResponseDTO(HttpStatus.OK, 'success', [], meta); + } + + } else { + const usersWithMobile = await this.user.find({ mobile: userIdentifier }); + if (usersWithMobile.length > 0) { + const userIds = usersWithMobile.map((user) => user._id); + query.userId = { $in: userIds }; + } else { + // Return empty result with pagination metadata + const pageOptions = Object.assign(new PageOptionsDto(), { + page: filters.page || 1, + take: filters.limit || 50, + }); + const meta = new PageMetaDto({ pageOptionsDto: pageOptions, itemCount: 0 }); + return new BaseResponseDTO(HttpStatus.OK, 'success', [], meta); + } + } + } + + // Expert filter + if (filters.expert) { + const identifier = filters.expert.trim(); + const isEmail = /@/.test(identifier); + + if (isEmail) { + query.expert = identifier; + } else { + const normalizedName = identifier + .replace(/ي/g, 'ی') + .replace(/ك/g, 'ک'); + const searchRegex = new RegExp(normalizedName, 'i'); + + const expertsFound = await this.admin.find({ + role: Role.Expert, + $or: [ + { name: { $regex: searchRegex } }, + { family: { $regex: searchRegex } }, + ], + }); + + if (expertsFound.length > 0) { + const expertUsernames = expertsFound.map( + (expert) => expert.username, + ); + query.expert = { $in: expertUsernames }; + } else { + // Return empty result with pagination metadata + const pageOptions = Object.assign(new PageOptionsDto(), { + page: filters.page || 1, + take: filters.limit || 50, + }); + const meta = new PageMetaDto({ pageOptionsDto: pageOptions, itemCount: 0 }); + return new BaseResponseDTO(HttpStatus.OK, 'success', [], meta); + } + } + } + + // Pagination setup + const page = filters.page || 1; + const limit = filters.limit || 50; + const skip = (page - 1) * limit; + + // Get total count for pagination metadata + const totalCount = await this.session.countDocuments(query).exec(); + + // Fetch sessions with pagination, lean for performance, and sort by creation date (newest first) + const sessions = await this.session + .find(query) + .select('_id chatTitle messages expert createdAt createdISO expertRate') + .sort({ createdISO: -1 }) // Sort by creation date descending (newest first) + .skip(skip) + .limit(limit) + .lean() + .exec(); + + const structuredData = sessions.map((session) => ({ + _id: session._id, + chatTitle: session.chatTitle, + subTitle: session.messages?.[1]?.text || '', + expert: session.expert, + createdAt: session.createdAt, + createdISO: session.createdISO, + expertRate: session.expertRate || null, + })); + + // Create pagination metadata + const pageOptions = Object.assign(new PageOptionsDto(), { + page: page, + take: limit, + }); + const meta = new PageMetaDto({ pageOptionsDto: pageOptions, itemCount: totalCount }); + + return new BaseResponseDTO(HttpStatus.OK, 'success', structuredData, meta); + } catch (err) { + console.error('Error in expertSessionsByAdmin:', err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async adminRateExpert(body: AdminRatesExpert) { + try { + const updateSession = await this.session.findOneAndUpdate( + { + _id: new Types.ObjectId(body.sessionId), + }, + { + adminRateToExpert: body.adminRate, + }, + { + new: true, + }, + ); + if (!updateSession) + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + + return new BaseResponseDTO(HttpStatus.OK, 'success', null); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async adminRateBot(body: AdminRatesExpert) { + try { + const updateSession = await this.session.findOneAndUpdate( + { + _id: new Types.ObjectId(body.sessionId), + }, + { + adminRateToBot: body.adminRate, + }, + { + new: true, + }, + ); + if (!updateSession) + throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); + + return new BaseResponseDTO(HttpStatus.OK, 'success', null); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async exportChatHistoryToExcel(filters: { + user?: string; + date?: string; + expert?: string; + status?: "all" | "bot" | "online"; + }): Promise { + try { + // Build the same query as expertSessionsByAdmin + const query: any = {}; + + // Status filter + if (filters.status === 'bot') { + query.connectedToExpert = false; + } else if (filters.status === 'online') { + query.connectedToExpert = true; + } + + // Date filter + if (filters.date) { + const dateParts = filters.date.split('-'); + + if (dateParts.length < 2 || !dateParts[0] || !dateParts[1]) { + throw new HttpException( + 'Invalid date range format. Expected "YYYY/MM/DD-YYYY/MM/DD".', + HttpStatus.BAD_REQUEST + ); + } + + const [startDate, endDate] = dateParts; + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + query.createdISO = { + $gte: startDateObj, + $lte: endDateObj, + }; + } + + // User mobile/name filter + if (filters.user) { + const userIdentifier = filters.user.trim(); + const isNameSearch = /\D/.test(userIdentifier); + + if (isNameSearch) { + const normalizedName = userIdentifier.replace(/ي/g, 'ی').replace(/ك/g, 'ک'); + const searchRegex = new RegExp(normalizedName, 'i'); + + const usersFound = await this.user.find({ + $or: [ + { name: { $regex: searchRegex } }, + { family: { $regex: searchRegex } }, + ], + }); + + if (usersFound.length > 0) { + const userIds = usersFound.map((user) => user._id); + query.userId = { $in: userIds }; + } else { + // No users found, return empty Excel + return this.createEmptyChatHistoryExcel(); + } + } else { + const usersWithMobile = await this.user.find({ mobile: userIdentifier }); + if (usersWithMobile.length > 0) { + const userIds = usersWithMobile.map((user) => user._id); + query.userId = { $in: userIds }; + } else { + return this.createEmptyChatHistoryExcel(); + } + } + } + + // Expert filter + if (filters.expert) { + const identifier = filters.expert.trim(); + const isEmail = /@/.test(identifier); + + if (isEmail) { + query.expert = identifier; + } else { + const normalizedName = identifier + .replace(/ي/g, 'ی') + .replace(/ك/g, 'ک'); + const searchRegex = new RegExp(normalizedName, 'i'); + + const expertsFound = await this.admin.find({ + role: Role.Expert, + $or: [ + { name: { $regex: searchRegex } }, + { family: { $regex: searchRegex } }, + ], + }); + + if (expertsFound.length > 0) { + const expertUsernames = expertsFound.map( + (expert) => expert.username, + ); + query.expert = { $in: expertUsernames }; + } else { + return this.createEmptyChatHistoryExcel(); + } + } + } + + // Fetch all sessions with full data + const sessions = await this.session.find(query).lean().exec(); + + // Fetch all unique user IDs and expert usernames + const userIds = [...new Set(sessions.map(s => s.userId.toString()))]; + const expertUsernames = [...new Set(sessions.map(s => s.expert).filter(Boolean))]; + + // Fetch user and expert details + const users = await this.user.find({ _id: { $in: userIds.map(id => new Types.ObjectId(id)) } }).lean().exec(); + const experts = await this.admin.find({ username: { $in: expertUsernames } }).lean().exec(); + + // Create maps for quick lookup + const userMap = new Map(users.map(u => [u._id.toString(), u])); + const expertMap = new Map(experts.map(e => [e.username, e])); + + // Create workbook + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Chatbot V2 API'; + workbook.created = new Date(); + + // Create Sessions Overview sheet + const overviewSheet = workbook.addWorksheet('نمای کلی گفتگوها'); + + // Style the header + overviewSheet.columns = [ + { header: 'شناسه گفتگو', key: 'sessionId', width: 25 }, + { header: 'عنوان گفتگو', key: 'chatTitle', width: 30 }, + { header: 'نام کاربر', key: 'userName', width: 20 }, + { header: 'موبایل کاربر', key: 'userMobile', width: 15 }, + { header: 'کد ملی کاربر', key: 'userNationalCode', width: 15 }, + { header: 'نام کارشناس', key: 'expertName', width: 20 }, + { header: 'موبایل کارشناس', key: 'expertMobile', width: 15 }, + { header: 'متصل به کارشناس', key: 'connectedToExpert', width: 15 }, + { header: 'امتیاز کارشناس', key: 'expertRate', width: 12 }, + { header: 'نظر ادمین به کارشناس', key: 'adminRateExpert', width: 18 }, + { header: 'نظر ادمین به ربات', key: 'adminRateBot', width: 18 }, + { header: 'تعداد پیام‌ها', key: 'messageCount', width: 12 }, + { header: 'تاریخ ایجاد', key: 'createdDate', width: 15 }, + { header: 'ساعت ایجاد', key: 'createdTime', width: 12 }, + { header: 'تاریخ شروع آنلاین', key: 'onlineStartDate', width: 20 }, + { header: 'تاریخ پایان آنلاین', key: 'onlineEndDate', width: 20 }, + { header: 'وضعیت بسته شدن', key: 'chatClosed', width: 15 }, + ]; + + // Apply header styling + overviewSheet.getRow(1).font = { bold: true, size: 11 }; + overviewSheet.getRow(1).alignment = { vertical: 'middle', horizontal: 'center' }; + overviewSheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF4472C4' } + }; + overviewSheet.getRow(1).font = { ...overviewSheet.getRow(1).font, color: { argb: 'FFFFFFFF' } }; + + // Add session data + sessions.forEach(session => { + const user = userMap.get(session.userId.toString()); + const expert = session.expert ? expertMap.get(session.expert) : null; + + overviewSheet.addRow({ + sessionId: session._id.toString(), + chatTitle: session.chatTitle || '', + userName: user ? `${user.name || ''} ${user.family || ''}`.trim() : 'نامشخص', + userMobile: user?.mobile || 'نامشخص', + userNationalCode: user?.nationalCode || 'نامشخص', + expertName: expert ? `${expert.name || ''} ${expert.family || ''}`.trim() : 'ربات', + expertMobile: expert?.mobile || '-', + connectedToExpert: session.connectedToExpert ? 'بله' : 'خیر', + expertRate: session.expertRate || '-', + adminRateExpert: session.adminRateToExpert === true ? 'لایک' : session.adminRateToExpert === false ? 'دیسلایک' : '-', + adminRateBot: session.adminRateToBot === true ? 'لایک' : session.adminRateToBot === false ? 'دیسلایک' : '-', + messageCount: session.messages?.length || 0, + createdDate: session.createdAt?.[1] || '', + createdTime: session.createdAt?.[0] || '', + onlineStartDate: session.onlineStartDate ? new Date(session.onlineStartDate).toLocaleString('fa-IR') : '-', + onlineEndDate: session.onlineEndDate ? new Date(session.onlineEndDate).toLocaleString('fa-IR') : '-', + chatClosed: session.chatClosed ? 'بسته شده' : 'باز', + }); + }); + + // Create Messages Detail sheet + const messagesSheet = workbook.addWorksheet('جزئیات پیام‌ها'); + + messagesSheet.columns = [ + { header: 'شناسه گفتگو', key: 'sessionId', width: 25 }, + { header: 'عنوان گفتگو', key: 'chatTitle', width: 30 }, + { header: 'شماره پیام', key: 'messageNumber', width: 10 }, + { header: 'فرستنده', key: 'sender', width: 15 }, + { header: 'متن پیام', key: 'messageText', width: 60 }, + { header: 'واکنش', key: 'react', width: 12 }, + { header: 'ویرایش شده', key: 'edited', width: 12 }, + { header: 'تاریخ ارسال', key: 'messageDate', width: 15 }, + { header: 'ساعت ارسال', key: 'messageTime', width: 12 }, + ]; + + // Apply header styling + messagesSheet.getRow(1).font = { bold: true, size: 11 }; + messagesSheet.getRow(1).alignment = { vertical: 'middle', horizontal: 'center' }; + messagesSheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF70AD47' } + }; + messagesSheet.getRow(1).font = { ...messagesSheet.getRow(1).font, color: { argb: 'FFFFFFFF' } }; + + // Add message data + sessions.forEach(session => { + if (session.messages && session.messages.length > 0) { + session.messages.forEach((message, index) => { + let senderDisplay = ''; + const senderStr = String(message.sender).toLowerCase(); + if (senderStr === 'user') { + const user = userMap.get(session.userId.toString()); + senderDisplay = user ? `کاربر: ${user.name || ''} ${user.family || ''}`.trim() : 'کاربر'; + } else if (senderStr === 'expert') { + const expert = session.expert ? expertMap.get(session.expert) : null; + senderDisplay = expert ? `کارشناس: ${expert.name || ''} ${expert.family || ''}`.trim() : 'کارشناس'; + } else if (senderStr === 'bot') { + senderDisplay = 'ربات'; + } else { + senderDisplay = String(message.sender); + } + + messagesSheet.addRow({ + sessionId: session._id.toString(), + chatTitle: session.chatTitle || '', + messageNumber: index + 1, + sender: senderDisplay, + messageText: message.text || '', + react: message.react || 'Nothing', + edited: message.edited ? 'بله' : 'خیر', + messageDate: message.createdAt?.[1] || '', + messageTime: message.createdAt?.[0] || '', + }); + }); + } + }); + + // Generate buffer + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + + } catch (err) { + console.error('Error in exportChatHistoryToExcel:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.message || 'Internal Server Error', + null, + ); + } + } + + private async createEmptyChatHistoryExcel(): Promise { + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Chatbot V2 API'; + workbook.created = new Date(); + + const sheet = workbook.addWorksheet('نمای کلی گفتگوها'); + sheet.addRow(['هیچ گفتگویی با فیلترهای انتخابی یافت نشد']); + + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + } +} diff --git a/src/conversation/dto/admin.dto.ts b/src/conversation/dto/admin.dto.ts new file mode 100644 index 0000000..aafe925 --- /dev/null +++ b/src/conversation/dto/admin.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AdminRatesExpert { + @ApiProperty({ + required: false, + type: 'string', + description: 'chat session id', + example: '', + }) + sessionId: string; + + @ApiProperty({ + required: true, + type: 'boolean', + description: + 'admin up-vote or down-vote the expert after they see experts chat with user', + example: 'true', + }) + adminRate: boolean; +} + +export class AdminRatesBot { + @ApiProperty({ + required: false, + type: 'string', + description: 'chat session id', + example: '', + }) + sessionId: string; + + @ApiProperty({ + required: true, + type: 'boolean', + description: + 'admin up-vote or down-vote the bot after they see bots chat with user', + example: 'true', + }) + adminRate: boolean; +} diff --git a/src/conversation/dto/create-conversation.dto.ts b/src/conversation/dto/create-conversation.dto.ts new file mode 100644 index 0000000..8b3bd5d --- /dev/null +++ b/src/conversation/dto/create-conversation.dto.ts @@ -0,0 +1 @@ +export class CreateConversationDto {} diff --git a/src/conversation/dto/update-conversation.dto.ts b/src/conversation/dto/update-conversation.dto.ts new file mode 100644 index 0000000..cd3976e --- /dev/null +++ b/src/conversation/dto/update-conversation.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateConversationDto } from './create-conversation.dto'; + +export class UpdateConversationDto extends PartialType(CreateConversationDto) {} diff --git a/src/database/database.module.ts b/src/database/database.module.ts new file mode 100644 index 0000000..665266b --- /dev/null +++ b/src/database/database.module.ts @@ -0,0 +1,149 @@ +import { HttpModule } from '@nestjs/axios'; +import { forwardRef, Module, OnModuleInit } from '@nestjs/common'; +import { InjectModel, MongooseModule } from '@nestjs/mongoose'; +import { ConfigService } from '@nestjs/config'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { Model } from 'mongoose'; +import { AuthService } from 'src/auth/auth.service'; +import { AdminService } from 'src/api/admin/admin.service'; +import { RedisService } from 'src/common/helpers/redis.service'; +import { SamanSmsService } from 'src/externals/sms/saman.service'; +import { SsoService } from 'src/externals/sso/sso.service'; +import { UploadModule } from 'src/upload/upload.module'; +import { PoliciesModule } from 'src/policies/policies.module'; +import { AdminModel, AdminSchema } from './model/admin.model'; +import { CategoriesModel, CategoriesSchema } from './model/categories.model'; +import { ClientModel, ClientSchema } from './model/client.model'; +import { + DictionariesModel, + DictionariesSchema, +} from './model/dictionaries.model'; +import { + ExternalTokensModel, + ExternalTokenSchema, +} from './model/externalTokens.model'; +import { FileUploadModel, FileUploadSchema } from './model/fileUpload.model'; +import { + QuestionModificationModel, + QuestionModificationSchema, +} from './model/questionModification.model'; +import { SessionModel, SessionSchema } from './model/sessions.model'; +import { UserModel, UserSchema } from './model/user.model'; +import { ReactsModel, ReactSchema } from './model/botReact.model'; +import { + BusinessHoursModel, + BusinessHoursSchema, +} from './model/business-hours.model'; +import { + ReassignedLogsModel, + ReassignedLogsSchema, +} from './model/reassigned-logs.model'; +import { + AuditLogModel, + AuditLogSchema, +} from './model/audit-log.model'; +import { + ChatMessageAttachmentModel, + ChatMessageAttachmentSchema, +} from './model/chat-message-attachment.model'; +import { + DictionaryFileAssetModel, + DictionaryFileAssetSchema, +} from './model/dictionary-file-asset.model'; +import { + ExpertPreparedMessageModel, + ExpertPreparedMessageSchema, +} from './model/expert-prepared-message.model'; +import { + StaffRoleModel, + StaffRoleSchema, +} from './model/staff-role.model'; +import { AuditLogService } from 'src/common/services/audit-log.service'; +import { PermissionsService } from 'src/acl/permissions.service'; + +@Module({ + imports: [ + RedisModule.forRootAsync({ + useFactory: () => ({ + type: 'single', // or 'cluster' + url: process.env.REDIS_URL, + }), + }), + HttpModule.register({ + timeout: 5000, + maxRedirects: 5, + }), + MongooseModule.forRootAsync({ + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + uri: `mongodb://${configService.get('MONGO_INITDB_ROOT_USERNAME')}:${configService.get('MONGO_INITDB_ROOT_PASSWORD')}@${configService.get('MONGO_HOST')}:${configService.get('MONGO_PORT')}/chat_bot?authMechanism=DEFAULT&authSource=admin`, + }), + }), + MongooseModule.forFeature([ + { name: UserModel.name, schema: UserSchema }, + { name: SessionModel.name, schema: SessionSchema }, + { name: ClientModel.name, schema: ClientSchema }, + { name: AdminModel.name, schema: AdminSchema }, + { name: DictionariesModel.name, schema: DictionariesSchema }, + { name: FileUploadModel.name, schema: FileUploadSchema }, + { name: CategoriesModel.name, schema: CategoriesSchema }, + { name: ExternalTokensModel.name, schema: ExternalTokenSchema }, + { + name: QuestionModificationModel.name, + schema: QuestionModificationSchema, + }, + { name: ReactsModel.name, schema: ReactSchema }, + { name: BusinessHoursModel.name, schema: BusinessHoursSchema }, + { name: ReassignedLogsModel.name, schema: ReassignedLogsSchema }, + { name: AuditLogModel.name, schema: AuditLogSchema }, + { + name: DictionaryFileAssetModel.name, + schema: DictionaryFileAssetSchema, + }, + { + name: ChatMessageAttachmentModel.name, + schema: ChatMessageAttachmentSchema, + }, + { + name: ExpertPreparedMessageModel.name, + schema: ExpertPreparedMessageSchema, + }, + { name: StaffRoleModel.name, schema: StaffRoleSchema }, + ]), + forwardRef(() => UploadModule), + forwardRef(() => PoliciesModule), + ], + providers: [ + AdminService, + AuthService, + SamanSmsService, + SsoService, + RedisService, + AuditLogService, + PermissionsService, + ], + exports: [ + MongooseModule, + RedisService, + AdminService, + AuthService, + DatabaseModule, + HttpModule, + AuditLogService, + PermissionsService, + ], +}) +export class DatabaseModule implements OnModuleInit { + constructor( + @InjectModel(ClientModel.name) + private readonly clientModel: Model, + ) {} + async onModuleInit() { + const leanDocument = (await this.clientModel.find().lean().exec()).length; + // throw leanDocument == 0 ? new Error('Must Be Implements Client') : null + } + createClient(data: { name: string; email: string; company: string }) { + console.log(`Client created:`, data); + return { success: true, ...data }; + } +} diff --git a/src/database/model/admin.model.ts b/src/database/model/admin.model.ts new file mode 100644 index 0000000..8cff743 --- /dev/null +++ b/src/database/model/admin.model.ts @@ -0,0 +1,57 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; +import { RateModel } from './rate.model'; +import { UsersBaseModel } from './users-base.model'; + +export type AdminDocument = AdminModel & Document; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'admin', +}) +export class AdminModel extends UsersBaseModel { + @Prop({ type: Types.ObjectId, ref: 'FileUploadModel', required: false }) + avatar?: Types.ObjectId; + + @Prop() + password: string; + + @Prop({ default: [] }) + rate: RateModel[]; + + @Prop({ default: null }) + resetToken: string | null; + + @Prop({ default: true }) + isActive: Boolean; + + @Prop({ default: false }) + onlineStatus: Boolean; + + @Prop({ default: false }) + available: Boolean; + + @Prop({ default: 0 }) + activeSessions: number; + + @Prop({ default: 5 }) + maxSessions: number; + + /** Extra permissions beyond the role template (ADR 0001). */ + @Prop({ type: [String], default: [] }) + permissionGrants: string[]; + + /** Blocked permissions from the role template (ADR 0001). */ + @Prop({ type: [String], default: [] }) + permissionDenies: string[]; +} + +export const AdminSchema = SchemaFactory.createForClass(AdminModel); +AdminSchema.clearIndexes(); +AdminSchema.pre('save', function (next) { + this.updatedAt = new Date(); + // this.password = crypto.createHash('sha256').update(this.password).digest('hex'), + + next(); +}); diff --git a/src/database/model/audit-log.model.ts b/src/database/model/audit-log.model.ts new file mode 100644 index 0000000..14bd162 --- /dev/null +++ b/src/database/model/audit-log.model.ts @@ -0,0 +1,74 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: false }, + versionKey: false, + collection: 'audit_logs', +}) +export class AuditLogModel extends Document { + @Prop({ type: Types.ObjectId, ref: 'UserModel', required: false }) + userId?: Types.ObjectId; + + @Prop({ type: String, required: true }) + action: string; // e.g., 'joinRoom', 'sendMessage', 'expertOnline', etc. + + @Prop({ type: String, required: false }) + resource?: string; // e.g., 'Chat', 'Session', 'Expert', 'Message', etc. + + @Prop({ type: String, required: false }) + resourceId?: string; // e.g., sessionId, messageId, expertId, etc. + + @Prop({ type: Object, required: false }) + oldValues?: Record; // Previous state before change + + @Prop({ type: Object, required: false }) + newValues?: Record; // New state after change + + @Prop({ type: Object, required: false }) + changes?: Record; // Detailed changes + + @Prop({ type: String, required: false }) + ipAddress?: string; + + @Prop({ type: String, required: false }) + userAgent?: string; + + @Prop({ type: String, required: false }) + method?: string; // For WebSocket: event name, for HTTP: GET, POST, etc. + + @Prop({ type: String, required: false }) + endpoint?: string; // For WebSocket: roomId or path, for HTTP: route path + + @Prop({ type: Number, required: false }) + statusCode?: number; // HTTP status code or success indicator + + @Prop({ type: Object, required: false }) + metadata?: Record; // Additional context data + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; +} + +export const AuditLogSchema = SchemaFactory.createForClass(AuditLogModel); + +// Create index for common queries +AuditLogSchema.index({ userId: 1, createdAt: -1 }); +AuditLogSchema.index({ action: 1, createdAt: -1 }); +AuditLogSchema.index({ resource: 1, resourceId: 1 }); +AuditLogSchema.index({ createdAt: -1 }); + + + + + + + + + + + + + + + diff --git a/src/database/model/botReact.model.ts b/src/database/model/botReact.model.ts new file mode 100644 index 0000000..486c063 --- /dev/null +++ b/src/database/model/botReact.model.ts @@ -0,0 +1,42 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; +import { ReactEnum } from 'src/common/types/react.type'; +import { Sender } from 'src/common/types/sender.type'; + +@Schema({ + timestamps: { createdAt: 'createdAt' }, + collection: 'reacts', + versionKey: false, + _id: true, +}) + +export class ReactsModel { + @Prop({ _id: true, default: new Types.ObjectId() }) + sessionId: Types.ObjectId; + + @Prop({ _id: true, default: new Types.ObjectId() }) + messageId: Types.ObjectId; + + @Prop({ _id: true, default: new Types.ObjectId() }) + userId: Types.ObjectId; + + @Prop({ default: false }) + question: string; + + @Prop({ default: false }) + answer: string; + + @Prop({ default: null, type: String, enum: Sender }) + sender: Sender; + + @Prop({ default: null, enum: ReactEnum }) + react: string; + + @Prop({ type: [String], required: true }) + createdAt: [string, string]; // [time, date] + + @Prop({ type: Date, default: Date.now }) + createdISO: Date; +} + +export const ReactSchema = SchemaFactory.createForClass(ReactsModel); diff --git a/src/database/model/business-hours.model.ts b/src/database/model/business-hours.model.ts new file mode 100644 index 0000000..36edc18 --- /dev/null +++ b/src/database/model/business-hours.model.ts @@ -0,0 +1,69 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +export interface TimeInterval { + start: string; // HH:mm format + end: string; // HH:mm format +} + +export interface WeeklyWindow { + day: 'saturday' | 'sunday' | 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday'; + intervals: TimeInterval[]; +} + +export interface Exception { + date: string; // YYYY-MM-DD format + intervals: TimeInterval[]; // Empty array means closed + reason?: string; +} + +export interface BusinessHoursConfig { + timezone: string; // IANA timezone (e.g., 'Asia/Tehran') + globalToggle: boolean; // Master switch + weeklyWindows: WeeklyWindow[]; + exceptions: Exception[]; + policy?: { + onClose: 'allow_existing_until_end' | 'hard_close'; + }; + messageTemplate?: { + key: string; + default: string; + }; +} + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'business_hours', + _id: true, +}) +export class BusinessHoursModel extends Document { + @Prop({ type: Object, required: true }) + config: BusinessHoursConfig; + + @Prop({ type: [String], default: [] }) + createdBy: string[]; // Array of admin usernames + + @Prop({ type: String, required: false }) + updatedBy?: string; // Last admin username who updated + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Date, default: Date.now }) + updatedAt: Date; + + @Prop({ type: Boolean, default: true }) + isActive: boolean; // Only one should be active at a time +} + +export const BusinessHoursSchema = SchemaFactory.createForClass(BusinessHoursModel); + + + + + + + + + diff --git a/src/database/model/categories.model.ts b/src/database/model/categories.model.ts new file mode 100644 index 0000000..88c723d --- /dev/null +++ b/src/database/model/categories.model.ts @@ -0,0 +1,29 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'categories', + _id: true, +}) +export class CategoriesModel { + @Prop({ type: String }) + title: string; + + @Prop({ type: String }) + enTitle: string; + + @Prop({ type: Number }) + priority: number; + + @Prop({ type: String }) + icon: string; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Date, default: Date.now }) + updatedAt: Date; +} + +export const CategoriesSchema = SchemaFactory.createForClass(CategoriesModel); diff --git a/src/database/model/chat-message-attachment.model.ts b/src/database/model/chat-message-attachment.model.ts new file mode 100644 index 0000000..d131471 --- /dev/null +++ b/src/database/model/chat-message-attachment.model.ts @@ -0,0 +1,52 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; +import type { ChatAttachmentFileType } from 'src/storage/storage.types'; + +@Schema({ + collection: 'chat_message_attachments', + timestamps: { createdAt: 'uploadedAt', updatedAt: false }, + versionKey: false, +}) +export class ChatMessageAttachmentModel extends Document { + @Prop({ type: Types.ObjectId, required: true }) + messageId: Types.ObjectId; + + @Prop({ type: Types.ObjectId, required: true }) + sessionId: Types.ObjectId; + + @Prop({ required: true }) + uploaderId: string; + + @Prop({ required: true, enum: ['User', 'Expert'] }) + uploaderRole: 'User' | 'Expert'; + + @Prop({ required: true, enum: ['voice', 'image', 'document'] }) + fileType: ChatAttachmentFileType; + + @Prop({ required: true }) + storageKey: string; + + @Prop({ required: true, default: 'private' }) + bucket: string; + + @Prop({ required: true }) + originalFilename: string; + + @Prop({ required: true }) + mimeType: string; + + @Prop({ required: true }) + size: number; + + @Prop({ type: Number, required: false }) + durationSec?: number; + + @Prop({ required: true, default: 'ready', enum: ['ready'] }) + status: 'ready'; +} + +export const ChatMessageAttachmentSchema = SchemaFactory.createForClass( + ChatMessageAttachmentModel, +); + +ChatMessageAttachmentSchema.index({ sessionId: 1, messageId: 1 }, { unique: true }); diff --git a/src/database/model/chat.model.ts b/src/database/model/chat.model.ts new file mode 100644 index 0000000..30cd6c3 --- /dev/null +++ b/src/database/model/chat.model.ts @@ -0,0 +1,39 @@ +import { Prop, Schema } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; +import { MessagesModel } from './messages.model'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, +}) +export class ChatModel { + @Prop({ _id: true, default: new Types.ObjectId() }) + sessionId: Types.ObjectId; + + @Prop({ type: String }) + chatTitle: string; + + @Prop({ default: false }) + chatClosed: boolean; + + @Prop({ default: false }) + connectedToExpert: boolean; + + @Prop({ default: false }) + onlineChatClosed: boolean; + + @Prop({ default: null }) + expertRate: number | null; + + @Prop({ type: String }) + expert: string; + + @Prop({ type: [MessagesModel], default: [] }) + messages: MessagesModel[]; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Date, default: Date.now }) + updatedAt: Date; +} diff --git a/src/database/model/client.model.ts b/src/database/model/client.model.ts new file mode 100644 index 0000000..4b928fd --- /dev/null +++ b/src/database/model/client.model.ts @@ -0,0 +1,31 @@ +import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose'; +import { Document, HydratedDocument } from 'mongoose'; + +export interface ClientInterface { + name_fa: string; + + name_en: string; + + apiKey: string; + + status: string; +} + +export type ClientDocument = HydratedDocument; + +@Schema({ timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } }) +export class ClientModel extends Document { + @Prop({ required: true }) + name: string; + + @Prop({ required: true }) + enName: string; + + @Prop({ required: true, unique: true }) + apiKey: string; + + @Prop({ enum: ['active', 'inactive'], default: 'active' }) + status: string; +} + +export const ClientSchema = SchemaFactory.createForClass(ClientModel); diff --git a/src/database/model/dictionaries.model.ts b/src/database/model/dictionaries.model.ts new file mode 100644 index 0000000..7688d98 --- /dev/null +++ b/src/database/model/dictionaries.model.ts @@ -0,0 +1,77 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { QuestionsModel } from './questions.model'; + +export interface UpdatedBy { + name: string; // User's mobile number + updatedAt: { + time: string; // Time of update + date: string; // Date of update + }; +} + +@Schema({ + timestamps: false, + versionKey: false, + collection: 'dictionaries', + _id: true, +}) +export class DictionariesModel { + @Prop({ type: String }) + title: string; + + @Prop({ type: 'string' }) + category: string; + + @Prop({ type: String }) + type: 'Regulation' | 'Method'; + + @Prop() + questions: QuestionsModel[]; + + @Prop({ type: Boolean }) + isActive: boolean; + + @Prop({ type: [String] }) + fileId: string[]; + + @Prop({ type: String }) + icon: string; + + @Prop({ type: String, required: false }) + aiCollectionId: string; + + @Prop({ type: String, required: false }) + aiCollectionName: string; + + /** Cached from AI get-collections-with-descriptions / get-collection-description. */ + @Prop({ type: String, required: false }) + description?: string; + + @Prop({ type: [String], default: [] }) + keywords?: string[]; + + /** + * Sync bookkeeping vs AI SoT. + * ok | missing | deactivated | stale + */ + @Prop({ type: String, required: false, default: 'ok' }) + aiSyncStatus?: string; + + @Prop({ type: Date, required: false }) + lastSyncedAt?: Date; + + @Prop({ type: Array }) + createdAt: []; + + @Prop({ type: Date }) + createdISO: Date; + + @Prop({ type: Date }) + updatedAt: Date; + + @Prop({ type: [Object] }) + updatedBy: UpdatedBy[]; +} + +export const DictionariesSchema = + SchemaFactory.createForClass(DictionariesModel); diff --git a/src/database/model/dictionary-file-asset.model.ts b/src/database/model/dictionary-file-asset.model.ts new file mode 100644 index 0000000..91364a9 --- /dev/null +++ b/src/database/model/dictionary-file-asset.model.ts @@ -0,0 +1,34 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +@Schema({ + collection: 'dictionary_file_assets', + timestamps: { createdAt: 'uploadedAt', updatedAt: false }, + versionKey: false, +}) +export class DictionaryFileAssetModel extends Document { + @Prop({ required: true }) + category: string; + + @Prop({ type: Types.ObjectId, ref: 'AdminModel', required: true }) + uploadedBy: Types.ObjectId; + + @Prop({ required: true }) + originalFilename: string; + + /** Key inside the private bucket (no bucket name). */ + @Prop({ required: true, unique: true }) + storageKey: string; + + @Prop({ required: true, default: 'private' }) + bucket: string; + + @Prop({ required: true }) + mimeType: string; + + @Prop({ required: true }) + size: number; +} + +export const DictionaryFileAssetSchema = + SchemaFactory.createForClass(DictionaryFileAssetModel); diff --git a/src/database/model/expert-prepared-message.model.ts b/src/database/model/expert-prepared-message.model.ts new file mode 100644 index 0000000..ea353b5 --- /dev/null +++ b/src/database/model/expert-prepared-message.model.ts @@ -0,0 +1,54 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; +import { + ExpertPreparedMessageCategory, + EXPERT_PREPARED_MESSAGE_CATEGORIES, +} from 'src/common/types/expert-prepared-message-category.type'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'expert_prepared_messages', + _id: true, +}) +export class ExpertPreparedMessageModel extends Document { + @Prop({ type: String, required: true, trim: true }) + message: string; + + @Prop({ + type: String, + required: true, + enum: EXPERT_PREPARED_MESSAGE_CATEGORIES, + }) + category: ExpertPreparedMessageCategory; + + @Prop({ type: String, trim: true, default: '' }) + label: string; + + @Prop({ type: Boolean, default: true }) + isEnabled: boolean; + + @Prop({ type: Number, default: 0 }) + sortOrder: number; + + @Prop({ type: String, default: 'fa', trim: true }) + locale: string; + + @Prop({ type: String }) + createdBy?: string; + + @Prop({ type: String }) + updatedBy?: string; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Date, default: Date.now }) + updatedAt: Date; +} + +export const ExpertPreparedMessageSchema = SchemaFactory.createForClass( + ExpertPreparedMessageModel, +); + +ExpertPreparedMessageSchema.index({ isEnabled: 1, category: 1, sortOrder: 1 }); diff --git a/src/database/model/externalTokens.model.ts b/src/database/model/externalTokens.model.ts new file mode 100644 index 0000000..d9e2845 --- /dev/null +++ b/src/database/model/externalTokens.model.ts @@ -0,0 +1,42 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'externalTokens', + _id: true, +}) +export class ExternalTokensModel { + @Prop({ type: String }) + token: string; + + @Prop({ type: 'string' }) + url: string; + + @Prop({ type: Boolean }) + isActive: boolean; + + @Prop({ type: String }) + method: 'access' | 'refresh'; + + @Prop({ type: String }) + tokenType: 'Bearer' | 'Basic'; + + @Prop({ type: String }) + expiresIn: string; + + @Prop({ type: String }) + scope: string; + + @Prop({ type: Number }) + timestamps: Number; + + @Prop({ type: Array }) + createdAt: []; + + @Prop({ type: Array }) + updatedAt: []; +} + +export const ExternalTokenSchema = + SchemaFactory.createForClass(ExternalTokensModel); diff --git a/src/database/model/fileUpload.model.ts b/src/database/model/fileUpload.model.ts new file mode 100644 index 0000000..968b779 --- /dev/null +++ b/src/database/model/fileUpload.model.ts @@ -0,0 +1,34 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +export type FileUploadDocument = FileUploadModel & Document; +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'file', + _id: true, +}) +export class FileUploadModel { + @Prop({ required: true }) + originalName: string; + + @Prop({ required: true }) + filename: string; + + @Prop({ required: true }) + filePath: string; + + @Prop({ required: true }) + mimetype: string; + + @Prop({ required: true }) + size: number; + + @Prop({ required: true }) + type: string; // e.g., 'image', 'document' + + @Prop({ required: true }) + category: string; // e.g., 'profile', 'invoice' +} + +export const FileUploadSchema = SchemaFactory.createForClass(FileUploadModel); diff --git a/src/database/model/installment.model.ts b/src/database/model/installment.model.ts new file mode 100644 index 0000000..713d4df --- /dev/null +++ b/src/database/model/installment.model.ts @@ -0,0 +1,91 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'installments', + _id: true, +}) +export class InstallmentModel { + @Prop({ type: Number, required: true, index: true }) + policyId: number; + + @Prop({ type: Types.ObjectId, required: true, index: true }) + userId: Types.ObjectId; + + @Prop({ type: String, required: true, unique: true }) + installmentId: string; + + @Prop({ type: String, required: false }) + paymentMethod: string; + + @Prop({ type: String, required: false }) + installmentYear: string; + + @Prop({ type: Number, required: false }) + installmentNumber: number; + + @Prop({ type: Number, required: false }) + installmentPaymentAmount: number; + + @Prop({ type: Number, required: false }) + remainingAmount: number; + + @Prop({ type: String, required: false }) + installmentDate: string; + + @Prop({ type: String, required: false }) + installmentPaymentDate: string; + + @Prop({ type: String, required: false }) + installmentStatus: string; + + @Prop({ type: Number, required: false }) + totalInstallments: number; + + @Prop({ type: Number, required: false }) + paidInstallments: number; + + @Prop({ type: Number, required: false }) + upcomingInstallmentCount: number; + + @Prop({ type: Number, required: false }) + overdueInstallmentCount: number; + + @Prop({ type: String, required: false }) + bankId: string; + + @Prop({ type: Number, required: false }) + totalPremiumCollected: number; + + @Prop({ type: Boolean, default: true, index: true }) + isActive: boolean; + + @Prop({ type: String, required: false, index: true }) + syncBatchId: string; + + @Prop({ type: Date, required: false }) + lastSyncedAt: Date; + + @Prop({ type: Date, default: Date.now }) + lastUpdated: Date; + + @Prop({ type: Date }) + createdAt: Date; + + @Prop({ type: Date }) + updatedAt: Date; +} + +export const InstallmentSchema = SchemaFactory.createForClass(InstallmentModel); + +// Create compound indexes for efficient queries +InstallmentSchema.index({ policyId: 1, userId: 1 }); +InstallmentSchema.index({ userId: 1 }); +InstallmentSchema.index({ policyId: 1, installmentStatus: 1 }); +InstallmentSchema.index({ installmentId: 1 }, { unique: true }); +InstallmentSchema.index({ userId: 1, isActive: 1 }); +InstallmentSchema.index({ userId: 1, syncBatchId: 1 }); + +// Made with Bob diff --git a/src/database/model/messages.model.ts b/src/database/model/messages.model.ts new file mode 100644 index 0000000..330e58c --- /dev/null +++ b/src/database/model/messages.model.ts @@ -0,0 +1,26 @@ +import { Prop, Schema } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; +import { ReactEnum } from 'src/common/types/react.type'; +import { Sender } from 'src/common/types/sender.type'; + +@Schema({ + timestamps: { createdAt: 'createdAt' }, + versionKey: false, + _id: true, +}) +export class MessagesModel { + @Prop({ _id: true, default: new Types.ObjectId() }) + messageId: Types.ObjectId; + + @Prop({ default: false }) + text: string; + + @Prop({ default: null, type: Sender }) + sender: Sender; + + @Prop({ default: null, enum: ReactEnum }) + react: string; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; +} diff --git a/src/database/model/policy.model.ts b/src/database/model/policy.model.ts new file mode 100644 index 0000000..8c5a431 --- /dev/null +++ b/src/database/model/policy.model.ts @@ -0,0 +1,105 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'policies', + _id: true, +}) +export class PolicyModel { + @Prop({ type: Types.ObjectId, required: true, index: true }) + userId: Types.ObjectId; + + @Prop({ type: Number, required: true, index: true }) + policyId: number; + + @Prop({ type: String, required: true }) + policyNumber: string; + + @Prop({ type: String, required: true }) + insuranceLineName: string; + + @Prop({ type: Number, required: true }) + insuranceLineCode: number; + + @Prop({ type: String, required: true }) + policyHolderName: string; + + @Prop({ type: String, required: true }) + insuredName: string; + + @Prop({ type: String, required: true }) + policyIssueDate: string; + + @Prop({ type: String, required: true }) + policyBeginDate: string; + + @Prop({ type: String, required: true }) + policyEndDate: string; + + @Prop({ type: String, required: true }) + policyStatus: string; + + @Prop({ type: Number, required: true }) + policyStatusCode: number; + + @Prop({ type: String, required: true }) + uniquePolicyCode: string; + + @Prop({ type: String, required: false }) + referrerName: string; + + @Prop({ type: String, required: false }) + issuingUnit: string; + + @Prop({ type: String, required: false }) + policyHolderCode: string; + + @Prop({ type: Number, required: false }) + agentCode: number; + + @Prop({ type: String, required: true }) + policyType: string; // 'life', 'car', 'health', 'fire', 'cargo', 'equipment', 'travel', 'liability', 'personalAccident' + + @Prop({ type: String, required: false }) + address: string; + + @Prop({ type: String, required: false }) + licensePlate: string; + + @Prop({ type: String, required: false }) + vehicleType: string; + + @Prop({ type: Object, required: false }) + additionalData: Record; + + @Prop({ type: Boolean, default: true, index: true }) + isActive: boolean; + + @Prop({ type: String, required: false, index: true }) + syncBatchId: string; + + @Prop({ type: Date, required: false }) + lastSyncedAt: Date; + + @Prop({ type: Date, default: Date.now }) + lastUpdated: Date; + + @Prop({ type: Date }) + createdAt: Date; + + @Prop({ type: Date }) + updatedAt: Date; +} + +export const PolicySchema = SchemaFactory.createForClass(PolicyModel); + +// Create compound index for efficient queries +PolicySchema.index({ userId: 1, policyId: 1 }, { unique: true }); +PolicySchema.index({ policyId: 1 }); +PolicySchema.index({ userId: 1, policyStatusCode: 1 }); +PolicySchema.index({ userId: 1, isActive: 1 }); +PolicySchema.index({ userId: 1, syncBatchId: 1 }); + +// Made with Bob diff --git a/src/database/model/questionModification.model.ts b/src/database/model/questionModification.model.ts new file mode 100644 index 0000000..06095c7 --- /dev/null +++ b/src/database/model/questionModification.model.ts @@ -0,0 +1,56 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; + +export interface requestedBy { + name: string; // User's mobile number + _id: Types.ObjectId; +} + +@Schema({ collection: 'questionModification' }) +export class QuestionModificationModel { + @Prop({ type: Types.ObjectId, ref: 'DictionariesModel' }) + dictionaryId: Types.ObjectId; + + @Prop({ type: Types.ObjectId }) + questionId: Types.ObjectId; + + @Prop({ type: String }) + oldQuestion: string; + + @Prop({ type: String }) + oldAnswer: string; + + @Prop({ type: String }) + newQuestion: string; + + @Prop({ type: String }) + newAnswer: string; + + @Prop({ type: Types.ObjectId }) + requestedBy: requestedBy; // expert's mobile or ID + + @Prop({ type: String }) + status: 'pending' | 'approved' | 'rejected'; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Array }) + createdAtPersian: []; + + @Prop({ type: Date }) + reviewedAt?: Date; + + @Prop({ type: Array }) + reviewedAtPersian: []; + + @Prop({ type: Types.ObjectId }) + reviewedBy?: requestedBy; // admin's ID + + @Prop({ type: String }) + rejectionReason?: string; +} + +export const QuestionModificationSchema = SchemaFactory.createForClass( + QuestionModificationModel, +); diff --git a/src/database/model/questions.model.ts b/src/database/model/questions.model.ts new file mode 100644 index 0000000..cd955e7 --- /dev/null +++ b/src/database/model/questions.model.ts @@ -0,0 +1,44 @@ +import { Prop, Schema } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; + +export interface UpdatedBy { + name: string; // User's mobile number + updatedAt: { + time: string; // Time of update + date: string; // Date of update + }; +} +@Schema({ + timestamps: false, + versionKey: false, + _id: true, +}) +export class QuestionsModel { + @Prop({ _id: true, default: new Types.ObjectId() }) + _id: Types.ObjectId; + + @Prop({ type: 'string' }) + question: string; + + @Prop({ type: 'string' }) + answer: string; + + /** AI sync/export item id (UUID). Required for AI-first PUT/DELETE. */ + @Prop({ type: String, required: false }) + aiItemId?: string; + + @Prop({ type: Boolean, default: false }) + deleted: boolean; + + @Prop({ type: Array }) + createdAt: any[]; + + @Prop({ type: Number }) + createdISO: number; + + @Prop({ type: Array }) + updatedAt: any[]; + + @Prop({ type: [Object] }) + updatedBy: UpdatedBy[]; +} diff --git a/src/database/model/rate.model.ts b/src/database/model/rate.model.ts new file mode 100644 index 0000000..fcd6267 --- /dev/null +++ b/src/database/model/rate.model.ts @@ -0,0 +1,20 @@ +import { Prop, Schema } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, +}) +export class RateModel { + @Prop({ _id: true, default: new Types.ObjectId() }) + sessionId: Types.ObjectId; + + @Prop({ type: String }) + rate: string; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Date, default: Date.now }) + createdAtDate: Date; +} diff --git a/src/database/model/reassigned-logs.model.ts b/src/database/model/reassigned-logs.model.ts new file mode 100644 index 0000000..8f07106 --- /dev/null +++ b/src/database/model/reassigned-logs.model.ts @@ -0,0 +1,33 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'reassigned_logs', +}) +export class ReassignedLogsModel extends Document { + @Prop({ type: Types.ObjectId, ref: 'SessionModel', required: true }) + sessionId: Types.ObjectId; + + @Prop({ type: Types.ObjectId, ref: 'UserModel', required: true }) + userId: Types.ObjectId; + + @Prop({ type: String, required: true }) + from: string; // Expert email (from expert) + + @Prop({ type: String, required: false, default: null }) + to: string | null; // Expert email (to expert), or null if no expert available + + @Prop({ type: String, required: false, default: 'auto' }) + source: 'manual' | 'auto'; + + @Prop({ type: String, required: false }) + mode?: 'selective' | 'random'; + + @Prop({ type: String, required: false }) + reason?: string; +} + +export const ReassignedLogsSchema = SchemaFactory.createForClass(ReassignedLogsModel); + diff --git a/src/database/model/sessions.model.ts b/src/database/model/sessions.model.ts new file mode 100644 index 0000000..723a34b --- /dev/null +++ b/src/database/model/sessions.model.ts @@ -0,0 +1,125 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; +import { Sender } from 'src/common/types/sender.type'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'sessions', // Name of the new collection +}) +export class SessionModel extends Document { + @Prop({ type: Types.ObjectId, ref: 'UserModel', required: true }) + userId: Types.ObjectId; // Reference to the user who owns this session + + @Prop({ required: true }) + chatTitle: string; + + @Prop({ required: true, default: false }) + connectedToExpert: boolean; + + /** AI offered a human handoff; user still chooses whether to open online chat. */ + @Prop({ required: false, default: false }) + escalationOffered: boolean; + + @Prop({ required: true, default: false }) + onlineChatClosed: boolean; + + @Prop({ type: Date, required: false, default: null }) + onlineStartDate: Date; + + @Prop({ type: Date, required: false, default: null }) + onlineEndDate: Date; + + @Prop({ required: true, default: false }) + chatClosed: boolean; + + @Prop({ required: false }) + expert: string; + + /** Manual expert transfers per session (max 1). Auto-reassign does not increment this. */ + @Prop({ required: true, default: 0 }) + transferCount: number; + + @Prop({ required: false }) + expertRate: number; + + @Prop({ required: false }) + adminRateToExpert: boolean; + + @Prop({ required: false }) + adminRateToBot: boolean; + + @Prop({ required: false }) + roomId: string; + + @Prop({ type: [String], required: true }) + createdAt: [string, string]; // [time, date] + + @Prop({ type: Date, default: Date.now }) + createdISO: Date; + + @Prop({ + type: [ + { + messageId: { type: Types.ObjectId, required: true }, + text: { type: String, required: true }, + sender: { type: String, required: true }, + react: { type: String, default: 'Nothing' }, + runId: { type: String, required: false }, + aiStatus: { type: String, required: false }, + escalation: { type: Object, required: false }, + createdAt: { type: [String], required: true }, // [time, date] + createdISO: { type: Date, required: true }, + edited: { type: Boolean, default: false }, + messageType: { + type: String, + enum: ['text', 'image', 'voice', 'document'], + default: 'text', + }, + voiceDurationSec: { type: Number, required: false }, + voiceMimeType: { type: String, required: false }, + /** Image/document or generic attachment MIME (voice still uses voiceMimeType for compatibility). */ + mimeType: { type: String, required: false }, + replyTo: { + type: { + messageId: { type: Types.ObjectId, required: true }, + textPreview: { type: String, default: '' }, + sender: { type: String, required: true }, + createdISO: { type: Date, required: false }, + unavailable: { type: Boolean, default: false }, + }, + required: false, + }, + }, + ], + default: [], + }) + messages: { + messageId: Types.ObjectId; + text: string; + sender: Sender; + react: string; + createdAt: [string, string]; + createdISO: Date; + edited: boolean; + runId?: string; + aiStatus?: string; + escalation?: { + summary?: string; + handoff_context?: Record; + }; + messageType?: 'text' | 'image' | 'voice' | 'document'; + voiceDurationSec?: number; + voiceMimeType?: string; + mimeType?: string; + replyTo?: { + messageId: Types.ObjectId; + textPreview: string; + sender: Sender; + createdISO?: Date; + unavailable: boolean; + }; + }[]; +} + +export const SessionSchema = SchemaFactory.createForClass(SessionModel); diff --git a/src/database/model/staff-role.model.ts b/src/database/model/staff-role.model.ts new file mode 100644 index 0000000..9b899a2 --- /dev/null +++ b/src/database/model/staff-role.model.ts @@ -0,0 +1,33 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +export type StaffRoleDocument = StaffRoleModel & Document; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'staff_roles', +}) +export class StaffRoleModel { + /** Unique role slug stored on admin.role (e.g. admin, expert, content_manager). */ + @Prop({ required: true, unique: true, index: true }) + name: string; + + @Prop({ required: false }) + displayName?: string; + + @Prop({ type: [String], default: [] }) + permissions: string[]; + + /** System roles cannot be deleted or renamed. */ + @Prop({ default: false }) + isSystem: boolean; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Date, default: Date.now }) + updatedAt: Date; +} + +export const StaffRoleSchema = SchemaFactory.createForClass(StaffRoleModel); diff --git a/src/database/model/user-insurance-snapshot.model.ts b/src/database/model/user-insurance-snapshot.model.ts new file mode 100644 index 0000000..fb6e0b2 --- /dev/null +++ b/src/database/model/user-insurance-snapshot.model.ts @@ -0,0 +1,35 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'user_insurance_snapshots', + _id: true, +}) +export class UserInsuranceSnapshotModel { + @Prop({ type: Types.ObjectId, required: true, unique: true, index: true }) + userId: Types.ObjectId; + + @Prop({ type: String, required: true }) + syncBatchId: string; + + @Prop({ type: Date, required: true }) + syncedAt: Date; + + @Prop({ type: Object, required: true }) + user_insurance_data: Record; + + @Prop({ type: Object, required: true }) + user_installments_data: Record; + + @Prop({ type: Date }) + createdAt: Date; + + @Prop({ type: Date }) + updatedAt: Date; +} + +export const UserInsuranceSnapshotSchema = SchemaFactory.createForClass( + UserInsuranceSnapshotModel, +); diff --git a/src/database/model/user.model.ts b/src/database/model/user.model.ts new file mode 100644 index 0000000..31f9ea5 --- /dev/null +++ b/src/database/model/user.model.ts @@ -0,0 +1,37 @@ +import * as crypto from 'node:crypto'; +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { ChatModel } from './chat.model'; +import { UsersBaseModel } from './users-base.model'; + +@Schema({ + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }, + versionKey: false, + collection: 'user', + _id: true, +}) +export class UserModel extends UsersBaseModel { + @Prop({ default: [] }) + chat: ChatModel[]; + + @Prop({ + required: false, + select: true, + set: (otp: string) => + otp ? crypto.createHash('sha256').update(otp).digest('hex') : null, + expires: 120, // The OTP will automatically be removed after 120 seconds + }) + otp: string | null; + + @Prop({ type: Date, default: null }) + otpCreatedAt: Date | null; + + @Prop({ type: Number, default: 0 }) + otpAttempts: number; +} + +export const UserSchema = SchemaFactory.createForClass(UserModel); + +UserSchema.pre('save', function (next) { + this.updatedAt = new Date(); + next(); +}); diff --git a/src/database/model/users-base.model.ts b/src/database/model/users-base.model.ts new file mode 100644 index 0000000..27d3636 --- /dev/null +++ b/src/database/model/users-base.model.ts @@ -0,0 +1,76 @@ +import * as crypto from 'node:crypto'; +import { Prop, Schema } from '@nestjs/mongoose'; +import { Role } from 'src/common/types/role.type'; + +@Schema() +export class UsersBaseModel { + @Prop() + name: string; + + @Prop() + family: string; + + @Prop({ required: true }) + role: Role; + + @Prop() + fatherName: string; + + @Prop() + shenasnameseri: string; + + @Prop() + shenasnameserial: string; + + @Prop() + birthDate: string; + + @Prop() + gender: string; + + @Prop({ + required: false, + match: /^[0-9]{11}$/, + default: undefined, + }) + mobile: string; + + @Prop() + birthday: string; + + @Prop() + nationalCode: string; + + @Prop({ match: /^((?!\.)[\w\-_.]*[^.])(@\w+)(\.\w+(\.\w+)?[^.\W])$/ }) + email: string; + + @Prop() + address: string; + + @Prop({ + default: function () { + // Generate 6-digit number or use mobile + return ( + this.mobile || Math.floor(100000 + Math.random() * 900000).toString() + ); + }, + }) + username: string; + + @Prop({ type: Date }) + last_login: Date; + + @Prop({ type: Date, default: Date.now }) + createdAt: Date; + + @Prop({ type: Date, default: Date.now }) + updatedAt: Date; + + static validateOtp(otpInput: string, storedOtpHash: string): boolean { + const inputOtpHash = crypto + .createHash('sha256') + .update(otpInput) + .digest('hex'); + return inputOtpHash === storedOtpHash; + } +} diff --git a/src/dictionaries/dictionaries.controller.ts b/src/dictionaries/dictionaries.controller.ts new file mode 100644 index 0000000..55786bf --- /dev/null +++ b/src/dictionaries/dictionaries.controller.ts @@ -0,0 +1,415 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + Query, + HttpStatus, +} from '@nestjs/common'; +import { + ApiOperation, + ApiBody, + ApiDefaultResponse, + ApiBearerAuth, + ApiParam, + ApiTags, + ApiQuery, +} from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { AdminModel } from 'src/database/model/admin.model'; +import { DictionariesService } from './dictionaries.service'; +import { DictionaryAiSyncService } from './dictionary-ai-sync.service'; +import { + CreateDictionaryDto, + CreateQuestionDto, + ModificationRequestDto, +} from './dto/create-dictionary.dto'; +import { + UpdateDictionaryDto, + UpdateQuestionDto, + QuestionDto, +} from './dto/update-dictionary.dto'; +import { + CreateDictionaryFromAiDto, + LinkAiCollectionDto, +} from './dto/ai-sync.dto'; +import { ReactEnum } from 'src/common/types/react.type'; + +@ApiBearerAuth() +@Permissions(Permission.DictionariesRead) +@UseGuards(AdminGuard) +@ApiTags('dictionary & question') +@Controller('dictionaries') +export class DictionariesController { + constructor( + private readonly dictionariesService: DictionariesService, + private readonly aiSync: DictionaryAiSyncService, + ) {} + + @ApiBody({ type: CreateDictionaryDto }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @UseGuards(AdminGuard) + @Permissions(Permission.DictionariesCreate) + @Post() + createDictionary(@Body() createDictionaryDto: CreateDictionaryDto) { + return this.dictionariesService.createDictionary(createDictionaryDto); + } + + @Get('/list') + @ApiOperation({ + summary: + 'Get all dictionaries without questions (for listing) - filterable by date and category', + }) + @ApiQuery({ + name: 'category', + required: false, + type: String, + description: 'Filter by dictionary category', + example: 'fireInsurance', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1403/07/01-1403/11/07")', + example: '1403/07/01-1403/11/07', + }) + findAllDictionariesList( + @Query('category') category?: string, + @Query('date') dateRange?: string, + ) { + let startDate: string | undefined; + let endDate: string | undefined; + + if (dateRange) { + [startDate, endDate] = dateRange.split('-'); + } + + return this.dictionariesService.findAllDictionariesList({ + category, + startDate, + endDate, + }); + } + + @Get('/ai/unlinked') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ + summary: + 'List AI collections not linked to any Mongo dictionary (staff decides link/create)', + }) + async listUnlinkedAiCollections() { + const data = await this.aiSync.listUnlinkedAiCollections(); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } + + @Post('/ai/sync') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ + summary: 'Manually reconcile all linked dictionaries from AI (SoT)', + }) + async syncAllFromAi() { + const data = await this.aiSync.syncAllLinked(); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } + + @Post('/ai/sync/:collectionName') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ + summary: 'Manually reconcile one linked collection from AI (SoT)', + }) + @ApiParam({ name: 'collectionName' }) + async syncOneFromAi(@Param('collectionName') collectionName: string) { + const data = await this.aiSync.syncOneByCollectionName(collectionName); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } + + @Post('/ai/link') + @Permissions(Permission.DictionariesWrite) + @ApiOperation({ + summary: 'Link an existing Mongo dictionary to an AI collection and pull Q&A', + }) + @ApiBody({ type: LinkAiCollectionDto }) + async linkAiCollection(@Body() body: LinkAiCollectionDto) { + const data = await this.aiSync.linkDictionaryToCollection(body); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } + + @Post('/ai/create-from-collection') + @Permissions(Permission.DictionariesCreate) + @ApiOperation({ + summary: + 'Create a Mongo dictionary shell from an unlinked AI collection and pull Q&A', + }) + @ApiBody({ type: CreateDictionaryFromAiDto }) + async createFromAiCollection(@Body() body: CreateDictionaryFromAiDto) { + const data = await this.aiSync.createDictionaryFromAiCollection(body); + return new BaseResponseDTO(HttpStatus.CREATED, 'SUCCESS', data); + } + + @Get('/:dictionaryId/questions') + @ApiOperation({ + summary: + 'Get all questions for a specific dictionary - filterable by date and category', + }) + @ApiParam({ + name: 'dictionaryId', + description: 'The ID of the dictionary', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter questions by date range in Persian format (e.g., "1403/07/01-1403/11/07")', + example: '1403/07/01-1403/11/07', + }) + @ApiQuery({ + name: 'category', + required: false, + type: String, + description: 'Filter by dictionary category (must match the dictionary)', + example: 'fireInsurance', + }) + findDictionaryQuestions( + @Param('dictionaryId') dictionaryId: string, + @Query('date') dateRange?: string, + @Query('category') category?: string, + ) { + let startDate: string | undefined; + let endDate: string | undefined; + + if (dateRange) { + [startDate, endDate] = dateRange.split('-'); + } + + return this.dictionariesService.findDictionaryQuestions(dictionaryId, { + startDate, + endDate, + category, + }); + } + + @ApiOperation({ + summary: 'Get single dictionary details with id.', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @ApiParam({ name: 'dictionaryId' }) + @Get(':dictionaryId') + findOne(@Param('dictionaryId') dictionaryId?: string) { + return this.dictionariesService.findOne(dictionaryId); + } + + @ApiOperation({ + summary: 'Toggle active or deactive a dictionary', + }) + @ApiParam({ name: 'dictionaryId' }) + @Permissions(Permission.DictionariesWrite) + @Patch('/:dictionaryId/toggle-active') + async toggleActive(@Param('dictionaryId') dictionaryId?: string) { + return this.dictionariesService.toggleActive(dictionaryId); + } + + @ApiOperation({ + summary: 'Admin can modify some attributes of the uploaded dictionary.', + }) + @ApiParam({ name: 'dictionaryId' }) + @Permissions(Permission.DictionariesWrite) + @Patch(':dictionaryId') + update( + @Param('dictionaryId') dictionaryId: string, + @Body() updateDictionaryDto: UpdateDictionaryDto, + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.dictionariesService.updateDictionary( + dictionaryId, + updateDictionaryDto, + adminIdentity, + ); + } + + @ApiOperation({ + summary: + 'Admin gets all the questions in the system (excluding soft-deleted)', + }) + @ApiQuery({ + name: 'search', + required: false, + type: String, + description: 'Search in questions and answers', + example: 'بیمه مسافرتی', + }) + @Get('questions/List') + findAllQuestions(@Query('search') search?: string) { + return this.dictionariesService.findAllQuestions(search); + } + + @ApiOperation({ + summary: 'Admin can modify some attributes of the extracted questions', + }) + @ApiParam({ name: 'questionId' }) + @Permissions(Permission.DictionariesWrite) + @Patch('/:questionId/modify') + updateQuestion( + @Param('questionId') questionId: string, + @Body() updateQuestionDto: UpdateQuestionDto, + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.dictionariesService.updateQuestion( + questionId, + updateQuestionDto, + adminIdentity, + ); + } + + @ApiOperation({ + summary: 'Admin can update all questions within a dictionary', + }) + @ApiParam({ name: 'dictionaryId' }) + @ApiBody({ type: [QuestionDto] }) + @Permissions(Permission.DictionariesDirectEdit) + @Patch('/:dictionaryId/questions/update-all') + updateAllQuestions( + @Param('dictionaryId') dictionaryId: string, + @Body() updatedQuestions: QuestionDto[], + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.dictionariesService.updateAllQuestions( + dictionaryId, + updatedQuestions, + adminIdentity, + ); + } + + @Permissions(Permission.DictionariesApprove) + @ApiOperation({ + summary: 'get the list of modification request | admin', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @UseGuards(AdminGuard) + @ApiQuery({ + name: 'status', + required: false, + enum: ['approved', 'pending', 'rejected'], + }) + @Get('/get/modification-request') + getModificationRequests( + @Query('status') status?: 'approved' | 'pending' | 'rejected', + ) { + return this.dictionariesService.getAllModificationRequests(status); + } + + @Permissions(Permission.DictionariesApprove) + @ApiOperation({ + summary: 'approve or reject the modification request | admin', + }) + @ApiBody({ type: ModificationRequestDto }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @UseGuards(AdminGuard) + @Post('/post/modification-request') + approveModificationRequest( + @Body() modificationRequest: ModificationRequestDto, + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.dictionariesService.modificationRequestApprove( + modificationRequest, + adminIdentity, + ); + } + + @ApiOperation({ + summary: 'Admin create a question in a dictionary', + }) + @Permissions(Permission.DictionariesWrite) + @Post('/question/create') + createQuestion( + @Body() createQuestionDto: CreateQuestionDto, + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.dictionariesService.createQuestion( + createQuestionDto, + adminIdentity, + ); + } + + @ApiOperation({ + summary: 'Admin delete the extracted question', + }) + @ApiParam({ name: 'questionId' }) + @Permissions(Permission.DictionariesWrite) + @Delete('/:questionId/delete') + deleteQuestion( + @Param('questionId') questionId: string, + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.dictionariesService.deleteQuestion(questionId, adminIdentity); + } + + @Permissions(Permission.DictionariesApprove) + @ApiOperation({ + summary: 'get the list of bot answers which has reacts , LIKE or DISLIKE', + }) + @ApiDefaultResponse({}) + @ApiBearerAuth() + @UseGuards(AdminGuard) + @ApiQuery({ + name: 'status', + required: false, + type: String, + description: 'status of the reaction , LIKE or DISLIKE or ALL', + example: 'ALL', + enum: ['LIKE', 'DISLIKE', 'ALL'], + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31"). If not provided, returns all reacts.', + example: '1404/01/01-1404/01/31', + }) + @ApiQuery({ + name: 'page', + required: false, + type: Number, + description: 'Page number (starts from 1)', + example: 1, + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Number of items per page', + example: 50, + }) + @Get('/get/answer-reacts') + getReactedAnswers( + @Query('status') status?: ReactEnum, + @Query('date') dateRange?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + const pageNum = page ? parseInt(page, 10) : 1; + const limitNum = limit ? parseInt(limit, 10) : 50; + + return this.dictionariesService.getAnswerReacts( + status, + dateRange, + pageNum, + limitNum, + ); + } +} diff --git a/src/dictionaries/dictionaries.module.ts b/src/dictionaries/dictionaries.module.ts new file mode 100644 index 0000000..4a9fb8d --- /dev/null +++ b/src/dictionaries/dictionaries.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { AiServiceModule } from 'src/ai-service/ai-service.module'; +import { DatabaseModule } from 'src/database/database.module'; +import { StorageModule } from 'src/storage/storage.module'; +import { DictionariesController } from './dictionaries.controller'; +import { DictionaryFileAssetsController } from './dictionary-file-assets.controller'; +import { DictionaryFileAssetsService } from './dictionary-file-assets.service'; +import { DictionariesService } from './dictionaries.service'; +import { DictionaryAiSyncService } from './dictionary-ai-sync.service'; + +@Module({ + imports: [DatabaseModule, AiServiceModule, StorageModule], + controllers: [DictionariesController, DictionaryFileAssetsController], + providers: [ + DictionariesService, + DictionaryFileAssetsService, + DictionaryAiSyncService, + ], + exports: [DictionaryAiSyncService], +}) +export class DictionariesModule {} diff --git a/src/dictionaries/dictionaries.service.ts b/src/dictionaries/dictionaries.service.ts new file mode 100644 index 0000000..2dc9332 --- /dev/null +++ b/src/dictionaries/dictionaries.service.ts @@ -0,0 +1,1536 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import * as csv from 'fast-csv'; +import { Model, Types } from 'mongoose'; +import { PermissionsService } from 'src/acl/permissions.service'; +import { AiServiceService } from 'src/ai-service/ai-service.service'; +import { BaseResponseDTO, PageOptionsDto, PageMetaDto } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { DictionariesModel } from 'src/database/model/dictionaries.model'; +import { FileUploadModel } from 'src/database/model/fileUpload.model'; +import { QuestionModificationModel } from 'src/database/model/questionModification.model'; +import { CategoriesModel } from 'src/database/model/categories.model'; // Added CategoriesModel import +import { ReactsModel } from 'src/database/model/botReact.model'; // Added ReactsModel import +import { UserModel } from 'src/database/model/user.model'; +import { + CreateDictionaryDto, + CreateQuestionDto, + ModificationRequestDto, +} from './dto/create-dictionary.dto'; +import { + UpdateDictionaryDto, + UpdateQuestionDto, + QuestionDto, +} from './dto/update-dictionary.dto'; +import { Sender } from 'src/common/types/sender.type'; +import { ReactEnum } from 'src/common/types/react.type'; +import { AdminModel } from 'src/database/model/admin.model'; +import { DictionaryAiSyncService } from './dictionary-ai-sync.service'; + +@Injectable() +export class DictionariesService { + constructor( + @InjectModel(FileUploadModel.name) + private readonly fileModel: Model, + @InjectModel(DictionariesModel.name) + private readonly dictionariesModel: Model, + @InjectModel(QuestionModificationModel.name) + private readonly questionModification: Model, + @InjectModel(CategoriesModel.name) // Injected CategoriesModel + private readonly categoriesModel: Model, + @InjectModel(ReactsModel.name) + private readonly reactsModel: Model, + @InjectModel(UserModel.name) + private readonly userModel: Model, + private readonly aiService: AiServiceService, + private readonly permissionsService: PermissionsService, + private readonly aiSync: DictionaryAiSyncService, + ) {} + + private getCurrentJalaliTime(): { time: string; date: string } { + const now = Date.now() / 1000; + const [time, date] = TimeHelper.unix2PersianTimeAndDate(now); + return { time, date }; + } + + // async createDictionary(body: CreateDictionaryDto) { + // const now = Date.now() / 1000; + // const results = []; + // let aiCollectionIdForCategory: string = null; + // let existingDictionaryForCategory; + + // try { + // const categoryDoc = await this.categoriesModel.findOne({ _id: body.category }); + // if (!categoryDoc) { + // throw new HttpException( + // `Category with ID ${body.category} not found.`, + // HttpStatus.NOT_FOUND, + // ); + // } + // const categoryEnTitle = categoryDoc.enTitle; + + // // Check if a collection for this category already exists + // existingDictionaryForCategory = await this.dictionariesModel.findOne({ + // category: body.category, + // isActive: true, + // }); + + // // Check AI collections + // const aiCollections = await this.aiService.getCollections(); + // const aiCollectionNames = JSON.parse(aiCollections.replace(/'/g, '"')).map(name => name.toLowerCase()); + + // switch (body.func) { + // case 'create': + // console.log(`[createDictionary] Operation: CREATE for category: ${body.category}`); + // if (existingDictionaryForCategory) { + // throw new HttpException( + // `A collection already exists for category ${body.category}. Use 'update' or 'replace' function.`, + // HttpStatus.CONFLICT, + // ); + // } + // if (aiCollectionNames.includes(categoryEnTitle.toLowerCase())) { + // throw new HttpException( + // `AI collection with name ${categoryEnTitle} already exists. Use 'update' or 'replace' function.`, + // HttpStatus.CONFLICT, + // ); + // } + + // for (let i = 0; i < body.fileIds.length; i++) { + // const fileId = body.fileIds[i]; + // console.log(`[createDictionary][CREATE] Processing file with ID: ${fileId}`); + // const file = await this.fileModel.findOne({ _id: fileId }); + // if (!file) + // throw new HttpException( + // `file_not_found for ID: ${fileId}`, + // HttpStatus.NOT_FOUND, + // ); + + // console.log(`[createDictionary][CREATE] Original filename: ${file.originalName}`); + // const dynamicFileName = `${categoryEnTitle}${path.extname( + // file.originalName, + // )}`; + // console.log(`[createDictionary][CREATE] Dynamic filename: ${dynamicFileName}`); + + // // AI file upload + // let aiResponse; + // if (file.type === 'txt' || file.type === 'text/plain') { + // console.log('[createDictionary][CREATE] Calling AI aiUploadTxt...'); + // aiResponse = await this.aiService.aiUploadTxt(file); + // } else if (file.type === 'csv' || file.type === 'text/csv') { + // console.log('[createDictionary][CREATE] Calling AI aiUploadCsv...'); + // aiResponse = await this.aiService.aiUploadCsv(file); + // } + // const filenameMain = aiResponse.file_path.split('/').pop(); + // console.log(filenameMain) + // if (i === 0) { + // // First file: create new collection + // console.log('[createDictionary][CREATE] Calling AI service for new collection...'); + // await this.aiService.newCollection( + // categoryEnTitle, + // filenameMain, + // body.description, + // ); + // aiCollectionIdForCategory = categoryEnTitle; // Set AI collection ID to category enTitle + // console.log( + // `[createDictionary][CREATE] New AI collection ID ${aiCollectionIdForCategory} and name ${categoryEnTitle} saved for category ${body.category}`, + // ); + // } else { + // // Subsequent files: update existing collection + // console.log( + // `[createDictionary][CREATE] Calling AI service to update collection ${aiCollectionIdForCategory}...`, + // ); + // await this.aiService.updateCollection( + // aiCollectionIdForCategory, + // dynamicFileName, + // categoryEnTitle, + // ); + // } + + // console.log('[createDictionary][CREATE] AI collection operation successful.'); + + // const fullPath = path.join(process.cwd(), file.filePath); + + // console.log('[createDictionary][CREATE] Parsing CSV file...'); + // const parsedFile = await this.parseCsvFile(fullPath); + // const parsedFileWithDeleted = parsedFile.map((item) => ({ + // ...item, + // deleted: false, + // })); + // const filteredQuestions = parsedFileWithDeleted.filter( + // (item) => !item.deleted, + // ); + // const questionsArr = filteredQuestions.map((item) => ({ + // ...item, + // _id: new Types.ObjectId(), + // createdAt: TimeHelper.unix2PersianTimeAndDate(now), + // createdISO: Date.now(), + // updatedAt: TimeHelper.unix2PersianTimeAndDate(now), + // updatedBy: [], + // })); + + // const dic = await this.dictionariesModel.findOneAndUpdate( + // { category: body.category }, + // { + // title: categoryEnTitle, // Use categoryEnTitle as title + // category: body.category, + // type: body.type, + // isActive: true, + // $addToSet: { fileId: fileId }, // Add fileId to array + // icon: body.icon, + // createdAt: TimeHelper.unix2PersianTimeAndDate(now), + // createdISO: Date.now(), + // updatedBy: [], + // questions: questionsArr, + // aiCollectionId: aiCollectionIdForCategory, + // aiCollectionName: categoryEnTitle, + // }, + // { upsert: true, new: true, setDefaultsOnInsert: true }, + // ); + // results.push({ + // fileId: fileId, + // status: 'SUCCESS', + // dictionary: dic, + // }); + // } + // break; + + // case 'update': + // console.log(`[createDictionary] Operation: UPDATE for category: ${body.category}`); + // if (!existingDictionaryForCategory || !existingDictionaryForCategory.aiCollectionId) { + // throw new HttpException( + // `No existing collection found for category ${body.category}. Use 'create' function.`, + // HttpStatus.NOT_FOUND, + // ); + // } + // if (!aiCollectionNames.includes(categoryEnTitle.toLowerCase())) { + // throw new HttpException( + // `AI collection with name ${categoryEnTitle} does not exist. Use 'create' function.`, + // HttpStatus.NOT_FOUND, + // ); + // } + // aiCollectionIdForCategory = categoryEnTitle; // Set to categoryEnTitle + + // for (let i = 0; i < body.fileIds.length; i++) { + // const fileId = body.fileIds[i]; + // console.log(`[createDictionary][UPDATE] Processing file with ID: ${fileId}`); + // const file = await this.fileModel.findOne({ _id: fileId }); + // if (!file) + // throw new HttpException( + // `file_not_found for ID: ${fileId}`, + // HttpStatus.NOT_FOUND, + // ); + + // console.log(`[createDictionary][UPDATE] Original filename: ${file.originalName}`); + // const dynamicFileName = `${categoryEnTitle}${path.extname( + // file.originalName, + // )}`; + // console.log(`[createDictionary][UPDATE] Dynamic filename: ${dynamicFileName}`); + + // // AI file upload + // if (file.type === 'txt' || file.type === 'text/plain') { + // console.log('[createDictionary][UPDATE] Calling AI aiUploadTxt...'); + // await this.aiService.aiUploadTxt(file); + // } else if (file.type === 'csv' || file.type === 'text/csv') { + // console.log('[createDictionary][UPDATE] Calling AI aiUploadCsv...'); + // await this.aiService.aiUploadCsv(file); + // } + + // console.log( + // `[createDictionary][UPDATE] Calling AI service to update collection ${aiCollectionIdForCategory}...`, + // ); + // await this.aiService.updateCollection( + // aiCollectionIdForCategory, + // dynamicFileName, + // categoryEnTitle, + // ); + + // console.log('[createDictionary][UPDATE] AI collection operation successful.'); + + // const fullPath = path.join(process.cwd(), file.filePath); + + // console.log('[createDictionary][UPDATE] Parsing CSV file...'); + // const parsedFile = await this.parseCsvFile(fullPath); + // const parsedFileWithDeleted = parsedFile.map((item) => ({ + // ...item, + // deleted: false, + // })); + // const filteredQuestions = parsedFileWithDeleted.filter( + // (item) => !item.deleted, + // ); + // const questionsArr = filteredQuestions.map((item) => ({ + // ...item, + // _id: new Types.ObjectId(), + // createdAt: TimeHelper.unix2PersianTimeAndDate(now), + // createdISO: Date.now(), + // updatedAt: TimeHelper.unix2PersianTimeAndDate(now), + // updatedBy: [], + // })); + + // const dic = await this.dictionariesModel.findOneAndUpdate( + // { category: body.category }, + // { + // $addToSet: { fileId: fileId }, // Add fileId to array + // $set: { + // title: categoryEnTitle, // Use categoryEnTitle as title + // type: body.type, + // isActive: true, + // icon: body.icon, + // updatedBy: [], // This might need to be dynamic or pushed + // aiCollectionId: aiCollectionIdForCategory, + // aiCollectionName: categoryEnTitle, + // }, + // $push: { questions: { $each: questionsArr } }, // Push new questions + // }, + // { new: true }, + // ); + // results.push({ + // fileId: fileId, + // status: 'SUCCESS', + // dictionary: dic, + // }); + // } + // break; + + // case 'replace': + // console.log(`[createDictionary] Operation: REPLACE for category: ${body.category}`); + // if (!existingDictionaryForCategory || !existingDictionaryForCategory.aiCollectionId) { + // throw new HttpException( + // `No existing collection found for category ${body.category}. Cannot perform replace.`, + // HttpStatus.NOT_FOUND, + // ); + // } + // if (!aiCollectionNames.includes(categoryEnTitle.toLowerCase())) { + // throw new HttpException( + // `AI collection with name ${categoryEnTitle} does not exist. Cannot perform replace.`, + // HttpStatus.NOT_FOUND, + // ); + // } + // if (body.fileIds.length !== 1) { + // throw new HttpException( + // 'Replace operation expects exactly one file to be uploaded.', + // HttpStatus.BAD_REQUEST, + // ); + // } + + // aiCollectionIdForCategory = categoryEnTitle; // Set to categoryEnTitle + + // // Deactivate existing collection in AI service + // console.log(`[createDictionary][REPLACE] Deactivating AI collection: ${aiCollectionIdForCategory}`); + // await this.aiService.deactivateCollection(aiCollectionIdForCategory); + + // const fileIdToReplace = body.fileIds[0]; + // console.log(`[createDictionary][REPLACE] Processing new file with ID: ${fileIdToReplace} for replacement`); + // const newFile = await this.fileModel.findOne({ _id: fileIdToReplace }); + // if (!newFile) + // throw new HttpException( + // `file_not_found for ID: ${fileIdToReplace}`, + // HttpStatus.NOT_FOUND, + // ); + + // const dynamicFileNameForReplace = `${categoryEnTitle}${path.extname(newFile.originalName)}`; + // console.log(`[createDictionary][REPLACE] Dynamic filename for AI service: ${dynamicFileNameForReplace}`); + + // // AI file upload for the new file + // if (newFile.type === 'txt' || newFile.type === 'text/plain') { + // console.log('[createDictionary][REPLACE] Calling AI aiUploadTxt for new file...'); + // await this.aiService.aiUploadTxt(newFile); + // } else if (newFile.type === 'csv' || newFile.type === 'text/csv') { + // console.log('[createDictionary][REPLACE] Calling AI aiUploadCsv for new file...'); + // await this.aiService.aiUploadCsv(newFile); + // } + + // console.log('[createDictionary][REPLACE] Calling AI service for new collection (after deactivation)...'); + // await this.aiService.newCollection( + // categoryEnTitle, + // dynamicFileNameForReplace, + // body.description, + // ); + + // console.log('[createDictionary][REPLACE] AI collection operation successful.'); + + // const fullPathReplace = path.join(process.cwd(), newFile.filePath); + + // console.log('[createDictionary][REPLACE] Parsing CSV file...'); + // const parsedFileReplace = await this.parseCsvFile(fullPathReplace); + // const parsedFileWithDeletedReplace = parsedFileReplace.map((item) => ({ + // ...item, + // deleted: false, + // })); + // const filteredQuestionsReplace = parsedFileWithDeletedReplace.filter( + // (item) => !item.deleted, + // ); + // const questionsArrReplace = filteredQuestionsReplace.map((item) => ({ + // ...item, + // _id: new Types.ObjectId(), + // createdAt: TimeHelper.unix2PersianTimeAndDate(now), + // createdISO: Date.now(), + // updatedAt: TimeHelper.unix2PersianTimeAndDate(now), + // updatedBy: [], + // })); + + // // Update the existing dictionary entry with new data + // const dicReplace = await this.dictionariesModel.findOneAndUpdate( + // { category: body.category }, + // { + // $set: { + // title: categoryEnTitle, // Use categoryEnTitle as title + // type: body.type, + // isActive: true, + // fileId: [fileIdToReplace], // Replace fileId with new one + // icon: body.icon, + // updatedBy: [], // This might need to be dynamic or pushed + // questions: questionsArrReplace, // Replace questions with new ones + // aiCollectionId: aiCollectionIdForCategory, + // aiCollectionName: categoryEnTitle, + // }, + // }, + // { new: true }, + // ); + // results.push({ + // fileId: fileIdToReplace, + // status: 'SUCCESS', + // dictionary: dicReplace, + // }); + // break; + + // default: + // throw new HttpException('Invalid func provided', HttpStatus.BAD_REQUEST); + // } + // return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', results); + // } catch (err) { + // console.log(err); + // throw new BaseResponseDTO( + // err.status || HttpStatus.INTERNAL_SERVER_ERROR, + // err.response || err.message, + // null, + // ); + // } + // } + + async createDictionary(body: CreateDictionaryDto) { + try { + const nowUnix = Date.now() / 1000; + const nowPersian = TimeHelper.unix2PersianTimeAndDate(nowUnix); + + const { fileIds, func, category, description, icon, type } = body; + + if (!Array.isArray(fileIds) || fileIds.length === 0) + throw new HttpException('file_ids_required', HttpStatus.BAD_REQUEST); + if (!func || !category || !description || !icon || !type) + throw new HttpException('invalid_payload', HttpStatus.BAD_REQUEST); + + // Resolve category doc and english title + let categoryDoc = await this.categoriesModel.findOne({ _id: category }); + if (!categoryDoc) { + // Fallback: allow passing enTitle directly + categoryDoc = await this.categoriesModel.findOne({ enTitle: category }); + } + if (!categoryDoc) + throw new HttpException( + `category_not_found`, + HttpStatus.NOT_FOUND, + ); + const categoryEnTitle = categoryDoc.enTitle; + + // Fetch current AI collections + const aiCollectionNames = (await this.aiSync.listAiCollectionNames()).map((n) => + n.toLowerCase(), + ); + + // Find existing dictionary for category (active) + const existingDictionary = await this.dictionariesModel.findOne({ + category: categoryDoc._id?.toString() || category, + isActive: true, + }); + + const results: any[] = []; + + const uploadAndMaybeParse = async (fileId: string) => { + const file = await this.fileModel.findOne({ _id: fileId }); + if (!file) + throw new HttpException(`file_not_found: ${fileId}`, HttpStatus.NOT_FOUND); + + // Upload to AI service based on mimetype/extension + const ext = path.extname(file.originalName || '').toLowerCase(); + const isCsv = file.mimetype?.includes('csv') || ext === '.csv'; + const isTxt = file.mimetype?.includes('text/plain') || ext === '.txt'; + + if (isTxt) { + await this.aiService.aiUploadTxt(file); + } else if (isCsv) { + await this.aiService.aiUploadCsv(file); + } else { + throw new HttpException('unsupported_file_type', HttpStatus.BAD_REQUEST); + } + + // Local CSV parse is intentionally unused — AI is SoT; cache filled via sync/export. + return { file, isCsv, isTxt }; + }; + + const refreshCacheFromAi = async (dictionaryId: string) => { + await this.aiSync.pullCollectionIntoDictionary( + dictionaryId, + categoryEnTitle, + { description, keywords: [] }, + ); + return this.dictionariesModel.findById(dictionaryId); + }; + + switch ((func || '').toLowerCase()) { + case 'create': { + // Must NOT exist in AI and NOT exist in dictionaries; category must exist + if (existingDictionary) + throw new HttpException( + `dictionary_already_exists_for_category`, + HttpStatus.CONFLICT, + ); + if (aiCollectionNames.includes(categoryEnTitle.toLowerCase())) + throw new HttpException( + `ai_collection_already_exists`, + HttpStatus.CONFLICT, + ); + + for (let i = 0; i < fileIds.length; i++) { + const { file } = await uploadAndMaybeParse(fileIds[i]); + + // Create collection for first file, update for the rest + if (i === 0) { + await this.aiService.newCollection( + categoryEnTitle, + file.originalName, + description, + ); + } else { + await this.aiService.updateCollection( + categoryEnTitle, + file.originalName, + description, + ); + } + + const dic = await this.dictionariesModel.findOneAndUpdate( + { category: categoryDoc._id?.toString() || category }, + { + title: categoryEnTitle, + category: categoryDoc._id?.toString() || category, + type: type, + isActive: true, + $addToSet: { fileId: file._id.toString() }, + icon: icon, + description, + keywords: [], + createdAt: nowPersian, + createdISO: new Date(nowUnix * 1000), + updatedAt: new Date(), + updatedBy: [], + aiCollectionId: categoryEnTitle, + aiCollectionName: categoryEnTitle, + aiSyncStatus: 'stale', + questions: [], + }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ); + + const refreshed = await refreshCacheFromAi(String(dic._id)); + results.push({ + fileId: file._id.toString(), + status: 'SUCCESS', + dictionary: refreshed, + }); + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', results); + } + case 'update': { + // Must exist in AI and in dictionaries + if (!existingDictionary) + throw new HttpException(`dictionary_not_found`, HttpStatus.NOT_FOUND); + if (!aiCollectionNames.includes(categoryEnTitle.toLowerCase())) + throw new HttpException(`ai_collection_not_found`, HttpStatus.NOT_FOUND); + + for (let i = 0; i < fileIds.length; i++) { + const { file } = await uploadAndMaybeParse(fileIds[i]); + + await this.aiService.updateCollection( + categoryEnTitle, + file.originalName, + description, + ); + + const dic = await this.dictionariesModel.findOneAndUpdate( + { category: categoryDoc._id?.toString() || category }, + { + $addToSet: { fileId: file._id.toString() }, + $set: { + title: categoryEnTitle, + type: type, + isActive: true, + icon: icon, + description, + updatedAt: new Date(), + aiCollectionId: categoryEnTitle, + aiCollectionName: categoryEnTitle, + aiSyncStatus: 'stale', + }, + }, + { new: true }, + ); + const refreshed = await refreshCacheFromAi(String(dic._id)); + results.push({ + fileId: file._id.toString(), + status: 'SUCCESS', + dictionary: refreshed, + }); + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', results); + } + case 'replace': { + // Must exist in AI and in dictionaries + if (!existingDictionary) + throw new HttpException(`dictionary_not_found`, HttpStatus.NOT_FOUND); + if (!aiCollectionNames.includes(categoryEnTitle.toLowerCase())) + throw new HttpException(`ai_collection_not_found`, HttpStatus.NOT_FOUND); + + // If multiple files: replace by first, then update by the rest + const firstUpload = await uploadAndMaybeParse(fileIds[0]); + + await this.aiService.replaceCollection( + categoryEnTitle, + firstUpload.file.originalName, + description, + ); + + for (let i = 1; i < fileIds.length; i++) { + const up = await uploadAndMaybeParse(fileIds[i]); + await this.aiService.updateCollection( + categoryEnTitle, + up.file.originalName, + description, + ); + } + + const dicReplace = await this.dictionariesModel.findOneAndUpdate( + { category: categoryDoc._id?.toString() || category }, + { + $set: { + title: categoryEnTitle, + type: type, + isActive: true, + fileId: fileIds.map((id) => id.toString()), + icon: icon, + description, + updatedAt: new Date(), + updatedBy: [], + aiCollectionId: categoryEnTitle, + aiCollectionName: categoryEnTitle, + aiSyncStatus: 'stale', + questions: [], + }, + }, + { new: true }, + ); + + const refreshed = await refreshCacheFromAi(String(dicReplace._id)); + results.push({ + fileId: fileIds[0], + status: 'SUCCESS', + dictionary: refreshed, + }); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', results); + } + default: + throw new HttpException('invalid_func', HttpStatus.BAD_REQUEST); + } + } catch (err) { + console.log(err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || err.message, + null, + ); + } + } + + async createQuestion(createQuestionDto: CreateQuestionDto, adminIdentity: any) { + try { + const { question, answer, dictionaryId } = createQuestionDto; + + if (!question || !answer || !dictionaryId) + throw new HttpException('invalid_payload', HttpStatus.BAD_REQUEST); + + await this.aiSync.ensureFreshIfStale(dictionaryId); + + const dictionary = await this.dictionariesModel.findById(dictionaryId); + if (!dictionary) + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + if (!dictionary.isActive) + throw new HttpException('dictionary_not_active', HttpStatus.FORBIDDEN); + if (!dictionary.aiCollectionId || !dictionary.aiCollectionName) + throw new HttpException('ai_collection_not_configured', HttpStatus.BAD_REQUEST); + + const collectionName = dictionary.aiCollectionName || dictionary.aiCollectionId; + const aiResponse = await this.aiService.createExportItem(collectionName, { + q: question, + a: answer, + }); + + let aiItemId = this.aiSync.extractAiItemIdFromCreateResponse(aiResponse); + if (!aiItemId) { + // AI may not return id — full pull is the SoT refresh + await this.aiSync.pullCollectionIntoDictionary(dictionaryId, collectionName); + const refreshed = await this.dictionariesModel.findById(dictionaryId); + const created = refreshed?.questions + ?.filter((q: any) => !q.deleted) + ?.find( + (q: any) => q.question === question && q.answer === answer, + ); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', created ?? refreshed); + } + + const adminLabel = adminIdentity?.userData?.name + ? `${adminIdentity.userData.name} ${adminIdentity.userData.family ?? ''}`.trim() + : adminIdentity?.userData?.mobile ?? 'admin'; + + try { + const created = await this.aiSync.applyCreateToCache({ + dictionaryId, + question, + answer, + aiItemId, + adminLabel, + }); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', created); + } catch (cacheErr) { + await this.aiSync.markStale(dictionaryId); + throw cacheErr; + } + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + private parseCsvFile( + filePath: string, + ): Promise<{ question: string; answer: string }[]> { + return new Promise((resolve, reject) => { + const results: { question: string; answer: string }[] = []; + + fs.createReadStream(filePath) + .pipe(csv.parse({ headers: true })) + .on('data', (row) => { + results.push({ + question: row.q, + answer: row.a, + }); + }) + .on('end', () => resolve(results)) + .on('error', (err) => reject(err)); + }); + } + + /** + * Bulk replace via AI collection replace, then full sync/export into Mongo cache. + * Client payload is sent to AI as CSV; Mongo questions come only from export. + */ + private async syncDictionaryToAi(dictionaryId: string): Promise { + let tempCsvPath: string | null = null; + try { + const dictionary = await this.dictionariesModel.findById(dictionaryId); + if (!dictionary) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + if (!dictionary.aiCollectionId || !dictionary.aiCollectionName) { + console.warn( + `[syncDictionaryToAi] Dictionary ${dictionaryId} has no aiCollectionId/aiCollectionName, skipping AI sync`, + ); + return; + } + + const rows = dictionary.questions + .filter((q: any) => !q.deleted) + .map((q: any) => ({ q: q.question, a: q.answer })); + + if (rows.length === 0) { + console.warn( + `[syncDictionaryToAi] Dictionary ${dictionaryId} has no non-deleted questions, skipping AI sync`, + ); + return; + } + + const csvFileName = `${dictionary.aiCollectionName}_${Date.now()}.csv`; + tempCsvPath = path.join(process.cwd(), 'uploads', csvFileName); + + const uploadsDir = path.join(process.cwd(), 'uploads'); + if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); + } + + await new Promise((resolve, reject) => { + const writeStream = fs.createWriteStream(tempCsvPath); + csv + .write(rows, { headers: true }) + .pipe(writeStream) + .on('error', reject) + .on('finish', resolve); + }); + + const csvFile = { + filePath: tempCsvPath, + originalName: csvFileName, + mimetype: 'text/csv', + }; + + await this.aiService.aiUploadCsv(csvFile); + await this.aiService.replaceCollection( + dictionary.aiCollectionId, + csvFileName, + dictionary.title || dictionary.aiCollectionName, + ); + + await this.aiSync.pullCollectionIntoDictionary( + dictionaryId, + dictionary.aiCollectionName || dictionary.aiCollectionId, + ); + } finally { + if (tempCsvPath && fs.existsSync(tempCsvPath)) { + try { + fs.unlinkSync(tempCsvPath); + } catch (cleanupErr) { + console.error(`[syncDictionaryToAi] Failed to cleanup temp CSV: ${tempCsvPath}`, cleanupErr); + } + } + } + } + + async findAllDictionariesList(filters: { + category?: string; + startDate?: string; + endDate?: string; + }) { + try { + const query: any = {}; + + if (filters.category) { + query.category = filters.category; + } + if (filters.startDate && filters.endDate) { + const startISO = TimeHelper.jalaliToISO(filters.startDate); + const endISO = TimeHelper.jalaliToISO(filters.endDate); + + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + query.createdISO = { + $gte: startDateObj, + $lte: endDateObj, + }; + } + + // Use aggregation to get dictionaries with question count efficiently + const aggregationPipeline: any[] = [ + { $match: query }, + { + $project: { + _id: 1, + title: 1, + category: 1, + type: 1, + isActive: 1, + fileId: 1, + icon: 1, + aiCollectionId: 1, + aiCollectionName: 1, + createdAt: 1, + createdISO: { $toLong: '$createdISO' }, // Convert Date to timestamp + updatedAt: 1, + updatedBy: 1, + questionCount: { + $size: { + $filter: { + input: '$questions', + as: 'question', + cond: { $eq: ['$$question.deleted', false] }, + }, + }, + }, + }, + }, + ]; + + const dictionaries = await this.dictionariesModel + .aggregate(aggregationPipeline) + .exec(); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', dictionaries); + } catch (err) { + console.error('Error in findAllDictionariesList:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || err.message, + null, + ); + } + } + + async findDictionaryQuestions( + dictionaryId: string, + filters: { + startDate?: string; + endDate?: string; + category?: string; + }, + ) { + try { + await this.aiSync.ensureFreshIfStale(dictionaryId); + + // First, find the dictionary + const dictionary = await this.dictionariesModel.findById(dictionaryId); + + if (!dictionary) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + + // If category filter is provided, verify it matches the dictionary + if (filters.category && dictionary.category !== filters.category) { + throw new HttpException( + 'Category filter does not match the dictionary category', + HttpStatus.BAD_REQUEST, + ); + } + + // Filter questions + let filteredQuestions = dictionary.questions.filter( + (question) => !question.deleted, + ); + + // Apply date filter if provided + if (filters.startDate && filters.endDate) { + const startISO = TimeHelper.jalaliToISO(filters.startDate); + const endISO = TimeHelper.jalaliToISO(filters.endDate); + + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + const startTimestamp = startDateObj.getTime(); + const endTimestamp = endDateObj.getTime(); + + filteredQuestions = filteredQuestions.filter((question) => { + // createdISO is a number (timestamp) + const questionTimestamp = question.createdISO; + return ( + questionTimestamp >= startTimestamp && + questionTimestamp <= endTimestamp + ); + }); + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + dictionaryId: dictionary._id, + dictionaryTitle: dictionary.title, + dictionaryCategory: dictionary.category, + questions: filteredQuestions, + questionCount: filteredQuestions.length, + }); + } catch (err) { + console.error('Error in findDictionaryQuestions:', err); + if (err instanceof HttpException) { + throw new BaseResponseDTO(err.getStatus(), err.message, null); + } + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || err.message, + null, + ); + } + } + + async findOne(dictionaryId) { + try { + await this.aiSync.ensureFreshIfStale(dictionaryId); + const dictionary = await this.dictionariesModel.findOne({ + _id: dictionaryId, + }); + if (!dictionary) + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + const filteredQuestions = dictionary.questions.filter( + (question) => !question.deleted, + ); + + const filteredDictionary = { + ...dictionary.toObject(), + questions: filteredQuestions, + }; + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', filteredDictionary); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, err.response, null); + } + } + + async toggleActive(id: string): Promise { + try { + const dictionary = await this.dictionariesModel.findById(id).exec(); + if (!dictionary) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + dictionary.isActive = !dictionary.isActive; + await dictionary.save(); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', dictionary); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async updateDictionary( + dictionaryId: any, + updateDictionaryDto: UpdateDictionaryDto, + adminIdentity, + ) { + const now = Date.now() / 1000; + try { + const dictionary = await this.dictionariesModel.findOneAndUpdate( + { _id: dictionaryId }, + { + ...updateDictionaryDto, + updatedBy: { + name: adminIdentity.userData.name + ? `${adminIdentity.userData.name} ${adminIdentity.userData.family}` + : adminIdentity.userData.mobile, + updatedAt: TimeHelper.unix2PersianTimeAndDate(now), // todo push to updatedBy Array + }, + }, + { new: true }, + ); + if (!dictionary) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', dictionary); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async findAllQuestions(search?: string) { + try { + const query: any = { + isActive: true, + questions: { $exists: true, $not: { $size: 0 } }, + }; + + // Add search filter if provided + if (search?.trim()) { + const searchRegex = new RegExp(search.trim(), 'i'); + query.$or = [ + { 'questions.question': searchRegex }, + { 'questions.answer': searchRegex }, + ]; + } + + const activeDictionaries = await this.dictionariesModel.find(query); + + const structuredData = activeDictionaries.map((dictionary) => { + // Filter out deleted questions + let questions = dictionary.questions.filter((question) => !question.deleted); + + // If search is provided, filter questions that match the search + if (search?.trim()) { + const searchRegex = new RegExp(search.trim(), 'i'); + questions = questions.filter( + (question) => + searchRegex.test(question.question) || + searchRegex.test(question.answer), + ); + } + + return { + dicTitle: dictionary.title, + _id: dictionary._id, + category: dictionary.category, + isActive: dictionary.isActive, + questions, + }; + }).filter((dictionary) => dictionary.questions.length > 0); // Remove dictionaries with no matching questions + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', structuredData); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async updateQuestion( + questionId, + updateQuestionDto: UpdateQuestionDto, + adminIdentity, + ) { + const now = Date.now() / 1000; + try { + if (!updateQuestionDto.question || !updateQuestionDto.answer) { + throw new HttpException( + 'question_answer_required', + HttpStatus.BAD_REQUEST, + ); + } + + const canDirectEdit = await this.permissionsService.hasPermission( + String(adminIdentity.userData._id), + Permission.DictionariesDirectEdit, + ); + + if (canDirectEdit) { + const updatedQuestion = await this.updateQuestionDirectly( + questionId, + updateQuestionDto, + adminIdentity, + ); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', updatedQuestion); + } else { + // Without direct-edit permission (e.g. expert) — create modification request + return await this.createModificationRequest( + questionId, + updateQuestionDto, + adminIdentity, + ); + } + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async updateAllQuestions( + dictionaryId: string, + updatedQuestions: QuestionDto[], + adminIdentity, + ) { + try { + if (!updatedQuestions || updatedQuestions.length === 0) { + throw new HttpException( + 'questions_required', + HttpStatus.BAD_REQUEST, + ); + } + + for (const q of updatedQuestions) { + if (!q.question || !q.answer) { + throw new HttpException( + 'question_answer_required', + HttpStatus.BAD_REQUEST, + ); + } + } + + const dictionary = await this.dictionariesModel.findById(dictionaryId); + + if (!dictionary) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + if (!dictionary.aiCollectionId || !dictionary.aiCollectionName) { + throw new HttpException('ai_collection_not_configured', HttpStatus.BAD_REQUEST); + } + + const canDirectEdit = await this.permissionsService.hasPermission( + String(adminIdentity.userData._id), + Permission.DictionariesDirectEdit, + ); + if (!canDirectEdit) { + throw new HttpException( + 'unauthorized_access', + HttpStatus.FORBIDDEN, + ); + } + + // Stage desired live set in Mongo temporarily, push to AI via replace, then re-pull SoT. + const now = Date.now() / 1000; + const nowPersian = TimeHelper.unix2PersianTimeAndDate(now); + const nowPersianObj = this.getCurrentJalaliTime(); + const adminInfo = { + name: adminIdentity.userData.name + ? `${adminIdentity.userData.name} ${adminIdentity.userData.family ?? ''}`.trim() + : adminIdentity.userData.mobile, + _id: adminIdentity.userData._id, + updatedAt: nowPersianObj, + }; + + const questionsToSave = updatedQuestions.map((q) => { + const questionId = q._id || new Types.ObjectId(); + return { + ...q, + _id: questionId, + updatedAt: nowPersian, + updatedBy: q.updatedBy ? [...q.updatedBy, adminInfo] : [adminInfo], + createdAt: q.createdAt || nowPersian, + createdISO: q.createdISO || now * 1000, + deleted: false, + }; + }); + + await this.dictionariesModel.findOneAndUpdate( + { _id: dictionaryId }, + { $set: { questions: questionsToSave, aiSyncStatus: 'stale' } }, + { new: true }, + ); + + try { + await this.syncDictionaryToAi(dictionaryId); + } catch (err) { + await this.aiSync.markStale(dictionaryId); + throw err; + } + + const refreshed = await this.dictionariesModel.findById(dictionaryId); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', refreshed); + } catch (err) { + console.error(err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || err.message, + null, + ); + } + } + + private async createModificationRequest( + questionId, + updateQuestionDto, + expertIdentity, + ) { + // Find the original question + const now = Date.now() / 1000; + + const isPending = await this.questionModification.findOne({ + questionId: new Types.ObjectId(questionId), + status: 'pending', + }); + if (isPending) + throw new HttpException( + 'request_pending_modify', + HttpStatus.NOT_ACCEPTABLE, + ); + + const dictionary = await this.dictionariesModel.findOne({ + 'questions._id': new Types.ObjectId(questionId), + 'questions.deleted': false, + }); + + if (!dictionary) { + throw new HttpException('question_not_found', HttpStatus.NOT_FOUND); + } + + const question = dictionary.questions.find( + (q) => q._id.toString() === questionId, + ); + + // Create modification request + const request = await new this.questionModification({ + dictionaryId: dictionary._id, + questionId: new Types.ObjectId(questionId), + oldQuestion: question.question, + oldAnswer: question.answer, + newQuestion: updateQuestionDto.question, + newAnswer: updateQuestionDto.answer, + requestedBy: { + name: expertIdentity.userData.name + ? `${expertIdentity.userData.name} ${expertIdentity.userData.family}` + : expertIdentity.userData.mobile, + _id: expertIdentity.userData._id, + }, + status: 'pending', + createdAtPersian: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: now * 1000, + }); + + await request.save(); + + return new BaseResponseDTO(HttpStatus.OK, 'MODIFICATION_REQUEST_CREATED', { + message: 'request_submitted_for_approval', + requestId: request._id, + }); + } + + async getAnswerReacts( + status?: ReactEnum | string, + dateRange?: string, + page?: number, + limit?: number, + ) { + try { + // Build query based on status filter + const query: any = {}; + + if (status && status !== 'ALL') { + // Filter by specific status (LIKE or DISLIKE) + // Use case-insensitive regex to match variations like "like", "Like", "LIKE" + query.react = new RegExp(`^${status}$`, 'i'); + } + // If status is 'ALL' or undefined, return all reacts + + // Add date filter if provided + if (dateRange) { + const [startDate, endDate] = dateRange.split('-'); + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + query.createdISO = { + $gte: startDateObj, + $lte: endDateObj, + }; + } + + // Pagination setup + const pageNum = page || 1; + const limitNum = limit || 50; + const skip = (pageNum - 1) * limitNum; + + // Get total count for pagination metadata + const totalCount = await this.reactsModel.countDocuments(query).exec(); + + // Fetch reacts with pagination, sorted by creation date (newest first) + const reacts = await this.reactsModel + .find(query) + .sort({ createdISO: -1 }) // Sort by creation date descending (newest first) + .skip(skip) + .limit(limitNum) + .lean() + .exec(); + + // Get unique user IDs to fetch user data in batch + const userIds = [...new Set(reacts.map((react) => String(react.userId)))]; + const users = await this.userModel + .find({ _id: { $in: userIds.map((id) => new Types.ObjectId(id)) } }) + .select('_id mobile') + .lean() + .exec(); + + // Create a map for quick lookup + const userMap = new Map( + users.map((user) => [String(user._id), user.mobile || null]) + ); + + const formattedReacts = reacts.map((react) => ({ + _id: react._id, + sessionId: react.sessionId, + messageId: react.messageId, + userId: react.userId, + userPhoneNumber: userMap.get(String(react.userId)) || null, + question: react.question, + answer: react.answer, + sender: react.sender, + react: typeof react.react === 'string' ? react.react.toLowerCase() : react.react, + createdAt: react.createdAt, + createdISO: react.createdISO, + })); + + // Create pagination metadata + const pageOptions = Object.assign(new PageOptionsDto(), { + page: pageNum, + take: limitNum, + }); + const meta = new PageMetaDto({ pageOptionsDto: pageOptions, itemCount: totalCount }); + + return new BaseResponseDTO(HttpStatus.OK, 'REACTED_ANSWERS', formattedReacts, meta); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async getAllModificationRequests(status?: 'approved' | 'pending' | 'rejected') { + try { + const query: any = {}; + if (status) { + query.status = status; + } + const requests = await this.questionModification.find(query); + const structuredData = requests.map((request) => ({ + requestId: request._id, + dictionaryId: request.dictionaryId, + questionId: request.questionId, + status: request.status, + oldQuestion: request.oldQuestion, + oldAnswer: request.oldAnswer, + newQuestion: request.newQuestion, + newAnswer: request.newAnswer, + requestedBy: request.requestedBy, + reviewedBy: request.reviewedBy ? request.reviewedBy : null, + reviewedAtPersian: request.reviewedAtPersian + ? request.reviewedAtPersian + : null, + reviewedAt: request.reviewedAt ? request.reviewedAt : null, + dateTime: request.createdAtPersian, + createdISO: request.createdAt, + })); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', structuredData); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + private async updateQuestionDirectly( + questionId: string, + updateData: { question: string; answer: string }, + userIdentity: any, + ) { + const dictionary = await this.dictionariesModel.findOne({ + 'questions._id': new Types.ObjectId(questionId), + 'questions.deleted': false, + }); + if (!dictionary) { + throw new HttpException('question_not_found', HttpStatus.NOT_FOUND); + } + + await this.aiSync.ensureFreshIfStale(String(dictionary._id)); + + const fresh = await this.dictionariesModel.findById(dictionary._id); + const questionDoc = fresh?.questions?.find( + (q) => q._id.toString() === questionId && !q.deleted, + ); + if (!questionDoc) { + throw new HttpException('question_not_found', HttpStatus.NOT_FOUND); + } + if (!questionDoc.aiItemId) { + throw new HttpException('ai_item_id_required_sync_first', HttpStatus.CONFLICT); + } + if (!fresh.aiCollectionName && !fresh.aiCollectionId) { + throw new HttpException('ai_collection_not_configured', HttpStatus.BAD_REQUEST); + } + + const collectionName = fresh.aiCollectionName || fresh.aiCollectionId; + await this.aiService.updateExportItem(collectionName, questionDoc.aiItemId, { + q: updateData.question, + a: updateData.answer, + }); + + const adminLabel = userIdentity.userData.name + ? `${userIdentity.userData.name} ${userIdentity.userData.family ?? ''}`.trim() + : userIdentity.userData.mobile; + + try { + const updatedQuestion = await this.aiSync.applyUpdateToCache({ + dictionaryId: String(fresh._id), + questionId, + question: updateData.question, + answer: updateData.answer, + adminLabel, + }); + return { updatedQuestion, success: true }; + } catch (err) { + await this.aiSync.markStale(String(fresh._id)); + throw err; + } + } + + async modificationRequestApprove( + modificationRequestDto: ModificationRequestDto, + adminIdentity, + ) { + try { + const now = Date.now() / 1000; + const modificationRequest = await this.questionModification.findOne({ + _id: modificationRequestDto.requestId, + status: 'pending', + }); + if (!modificationRequest) + throw new HttpException('request_not_found', HttpStatus.NOT_FOUND); + + if (modificationRequestDto.approved == true) { + await this.updateQuestionDirectly( + modificationRequest.questionId.toString(), + { + question: modificationRequest.newQuestion, + answer: modificationRequest.newAnswer, + }, + adminIdentity, + ); + } + + await this.questionModification.updateOne( + { + _id: modificationRequestDto.requestId, + }, + { + status: + modificationRequestDto.approved == true ? 'approved' : 'reject', + reviewedBy: { + name: adminIdentity.userData.name + ? `${adminIdentity.userData.name} ${adminIdentity.userData.family}` + : adminIdentity.userData.mobile, + _id: adminIdentity.userData._id, + }, + reviewedAtPersian: TimeHelper.unix2PersianTimeAndDate(now), + reviewedAt: Date.now(), + }, + ); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', 'updated'); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async deleteQuestion(questionId, adminIdentity) { + try { + const dictionary = await this.dictionariesModel.findOne({ + 'questions._id': new Types.ObjectId(questionId), + 'questions.deleted': false, + }); + if (!dictionary) { + throw new HttpException('question_not_found', HttpStatus.NOT_FOUND); + } + + await this.aiSync.ensureFreshIfStale(String(dictionary._id)); + + const fresh = await this.dictionariesModel.findById(dictionary._id); + const questionDoc = fresh?.questions?.find( + (q) => q._id.toString() === questionId && !q.deleted, + ); + if (!questionDoc) { + throw new HttpException('question_not_found', HttpStatus.NOT_FOUND); + } + if (!questionDoc.aiItemId) { + throw new HttpException('ai_item_id_required_sync_first', HttpStatus.CONFLICT); + } + if (!fresh.aiCollectionName && !fresh.aiCollectionId) { + throw new HttpException('ai_collection_not_configured', HttpStatus.BAD_REQUEST); + } + + const collectionName = fresh.aiCollectionName || fresh.aiCollectionId; + await this.aiService.deleteExportItem(collectionName, questionDoc.aiItemId); + + try { + const deleted = await this.aiSync.applyDeleteToCache({ + dictionaryId: String(fresh._id), + questionId, + }); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', deleted); + } catch (err) { + await this.aiSync.markStale(String(fresh._id)); + throw err; + } + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } +} diff --git a/src/dictionaries/dictionary-ai-sync.service.ts b/src/dictionaries/dictionary-ai-sync.service.ts new file mode 100644 index 0000000..30cdca4 --- /dev/null +++ b/src/dictionaries/dictionary-ai-sync.service.ts @@ -0,0 +1,660 @@ +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { AiServiceService } from 'src/ai-service/ai-service.service'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { DictionariesModel } from 'src/database/model/dictionaries.model'; + +export type AiSyncStatus = 'ok' | 'missing' | 'deactivated' | 'stale'; + +const EXPORT_PAGE_SIZE = 50; + +@Injectable() +export class DictionaryAiSyncService { + private readonly logger = new Logger(DictionaryAiSyncService.name); + private readonly syncing = new Set(); + + constructor( + @InjectModel(DictionariesModel.name) + private readonly dictionariesModel: Model, + private readonly aiService: AiServiceService, + ) {} + + /** Hourly reconciliation of all linked dictionaries. */ + @Cron(CronExpression.EVERY_HOUR) + async hourlyReconcile() { + this.logger.log('Starting hourly AI dictionary reconciliation'); + try { + await this.syncAllLinked(); + } catch (err) { + this.logger.error('Hourly AI dictionary reconciliation failed', err as Error); + } + } + + coerceCollectionNames(val: unknown): string[] { + try { + if (Array.isArray(val)) return val.map((v) => String(v)); + if (typeof val === 'string') { + try { + const parsed = JSON.parse(val.replace(/'/g, '"')); + if (Array.isArray(parsed)) return parsed.map((v) => String(v)); + if (parsed && Array.isArray(parsed.data)) + return parsed.data.map((v: unknown) => String(v)); + } catch { + return val.split(',').map((s) => s.trim()).filter(Boolean); + } + } + if (val && typeof val === 'object') { + const obj = val as Record; + if (Array.isArray(obj.data)) return obj.data.map((v) => String(v)); + if (Array.isArray(obj.collections)) { + return obj.collections.map((v) => + typeof v === 'string' ? v : String((v as { collection?: string })?.collection ?? v), + ); + } + if (obj.collections && typeof obj.collections === 'object' && !Array.isArray(obj.collections)) { + return Object.keys(obj.collections as object); + } + } + } catch { + /* ignore */ + } + return []; + } + + parseCollectionsWithDescriptions(raw: unknown): Map< + string, + { description: string; keywords: string[] } + > { + const map = new Map(); + const root = raw as any; + const collections = root?.collections ?? raw; + if (!collections || typeof collections !== 'object') return map; + + if (Array.isArray(collections)) { + for (const row of collections) { + const name = String(row?.collection ?? row?.name ?? '').trim(); + if (!name) continue; + map.set(name, { + description: String(row?.description ?? ''), + keywords: Array.isArray(row?.keywords) + ? row.keywords.map((k: unknown) => String(k)) + : [], + }); + } + return map; + } + + for (const [name, meta] of Object.entries(collections as Record)) { + map.set(name, { + description: String(meta?.description ?? ''), + keywords: Array.isArray(meta?.keywords) + ? meta.keywords.map((k: unknown) => String(k)) + : [], + }); + } + return map; + } + + async listAiCollectionNames(): Promise { + const raw = await this.aiService.getCollections(); + return this.coerceCollectionNames(raw); + } + + async listUnlinkedAiCollections() { + const [names, linkedDocs, withDescRaw, checkRaw] = await Promise.all([ + this.listAiCollectionNames(), + this.dictionariesModel + .find({ + $or: [ + { aiCollectionName: { $exists: true, $nin: [null, ''] } }, + { aiCollectionId: { $exists: true, $nin: [null, ''] } }, + ], + }) + .select('aiCollectionName aiCollectionId') + .lean(), + this.aiService.getCollectionsWithDescriptions().catch(() => null), + this.aiService.checkCollections().catch(() => null), + ]); + + const linked = new Set( + linkedDocs + .map((d) => (d.aiCollectionName || d.aiCollectionId || '').toLowerCase()) + .filter(Boolean), + ); + + const descMap = this.parseCollectionsWithDescriptions(withDescRaw); + const countMap = new Map(); + const checkList = (checkRaw as any)?.collections; + if (Array.isArray(checkList)) { + for (const row of checkList) { + const name = String(row?.collection ?? '').trim(); + if (!name) continue; + countMap.set(name, { + count: Number(row?.count ?? 0), + empty: Boolean(row?.empty), + }); + } + } + + return names + .filter((n) => n && !linked.has(n.toLowerCase())) + .map((name) => { + const meta = descMap.get(name) ?? { description: '', keywords: [] as string[] }; + const counts = countMap.get(name); + return { + collection: name, + description: meta.description, + keywords: meta.keywords, + questionCount: counts?.count ?? null, + empty: counts?.empty ?? null, + }; + }); + } + + async syncAllLinked() { + const [aiNames, metaMap, checkRaw] = await Promise.all([ + this.listAiCollectionNames(), + this.aiService + .getCollectionsWithDescriptions() + .then((r) => this.parseCollectionsWithDescriptions(r)) + .catch(() => new Map()), + this.aiService.checkCollections().catch(() => null), + ]); + + const aiNameSet = new Set(aiNames.map((n) => n.toLowerCase())); + const emptyOrMissing = new Map(); + const checkList = (checkRaw as any)?.collections; + if (Array.isArray(checkList)) { + for (const row of checkList) { + const name = String(row?.collection ?? '').trim(); + if (!name) continue; + if (row.empty === true) emptyOrMissing.set(name.toLowerCase(), 'deactivated'); + } + } + + const linked = await this.dictionariesModel.find({ + $or: [ + { aiCollectionName: { $exists: true, $nin: [null, ''] } }, + { aiCollectionId: { $exists: true, $nin: [null, ''] } }, + ], + }); + + const results: Array<{ + dictionaryId: string; + collection: string; + status: string; + error?: string; + }> = []; + + for (const dic of linked) { + const collection = (dic.aiCollectionName || dic.aiCollectionId || '').trim(); + if (!collection) continue; + + const key = collection.toLowerCase(); + if (!aiNameSet.has(key)) { + await this.markMissingOrDeactivated(dic._id, 'missing'); + results.push({ + dictionaryId: String(dic._id), + collection, + status: 'missing', + }); + continue; + } + + if (emptyOrMissing.get(key) === 'deactivated') { + await this.markMissingOrDeactivated(dic._id, 'deactivated'); + // still pull questions so cache stays accurate + } + + try { + const meta = metaMap.get(collection); + await this.pullCollectionIntoDictionary(String(dic._id), collection, meta); + results.push({ + dictionaryId: String(dic._id), + collection, + status: 'ok', + }); + } catch (err: any) { + this.logger.error(`Sync failed for ${collection}`, err); + results.push({ + dictionaryId: String(dic._id), + collection, + status: 'error', + error: err?.message || String(err), + }); + } + } + + return { synced: results.length, results }; + } + + async syncOneByCollectionName(collectionName: string) { + const name = (collectionName || '').trim(); + if (!name) { + throw new HttpException('collection_name_required', HttpStatus.BAD_REQUEST); + } + + const dic = await this.dictionariesModel.findOne({ + $or: [ + { aiCollectionName: name }, + { aiCollectionId: name }, + { aiCollectionName: new RegExp(`^${escapeRegex(name)}$`, 'i') }, + { aiCollectionId: new RegExp(`^${escapeRegex(name)}$`, 'i') }, + ], + }); + if (!dic) { + throw new HttpException('dictionary_not_linked', HttpStatus.NOT_FOUND); + } + + const aiNames = await this.listAiCollectionNames(); + if (!aiNames.some((n) => n.toLowerCase() === name.toLowerCase())) { + await this.markMissingOrDeactivated(dic._id, 'missing'); + throw new HttpException('ai_collection_missing', HttpStatus.NOT_FOUND); + } + + let meta: { description: string; keywords: string[] } | undefined; + try { + const raw = await this.aiService.getCollectionsWithDescriptions(); + meta = this.parseCollectionsWithDescriptions(raw).get( + aiNames.find((n) => n.toLowerCase() === name.toLowerCase()) || name, + ); + } catch { + /* optional */ + } + + await this.pullCollectionIntoDictionary(String(dic._id), dic.aiCollectionName || name, meta); + return { dictionaryId: String(dic._id), collection: dic.aiCollectionName || name, status: 'ok' }; + } + + /** + * Lazy repair: if cache marked stale, re-pull before serving. + */ + async ensureFreshIfStale(dictionaryId: string) { + const dic = await this.dictionariesModel.findById(dictionaryId); + if (!dic) return; + if (dic.aiSyncStatus !== 'stale') return; + const collection = (dic.aiCollectionName || dic.aiCollectionId || '').trim(); + if (!collection) return; + await this.pullCollectionIntoDictionary(String(dic._id), collection); + } + + async markStale(dictionaryId: string | Types.ObjectId) { + await this.dictionariesModel.updateOne( + { _id: dictionaryId }, + { $set: { aiSyncStatus: 'stale' as AiSyncStatus } }, + ); + } + + private async markMissingOrDeactivated( + dictionaryId: Types.ObjectId, + status: 'missing' | 'deactivated', + ) { + await this.dictionariesModel.updateOne( + { _id: dictionaryId }, + { + $set: { + isActive: false, + aiSyncStatus: status, + lastSyncedAt: new Date(), + }, + }, + ); + } + + /** + * Full export merge: upsert by aiItemId, soft-delete absentees. + */ + async pullCollectionIntoDictionary( + dictionaryId: string, + collectionName: string, + meta?: { description: string; keywords: string[] }, + ) { + const lockKey = dictionaryId; + if (this.syncing.has(lockKey)) { + this.logger.warn(`Sync already in progress for dictionary ${dictionaryId}`); + return; + } + this.syncing.add(lockKey); + + try { + const dic = await this.dictionariesModel.findById(dictionaryId); + if (!dic) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + + const exportedItems: Array<{ id: string; q: string; a: string }> = []; + let page = 1; + let totalPages = 1; + + do { + const pageData = await this.aiService.exportCollectionPage( + collectionName, + page, + EXPORT_PAGE_SIZE, + ); + totalPages = Math.max(1, Number(pageData?.total_pages ?? 1)); + const items = Array.isArray(pageData?.items) ? pageData.items : []; + for (const item of items) { + if (item?.id) exportedItems.push(item); + } + page += 1; + } while (page <= totalPages); + + const nowUnix = Date.now() / 1000; + const nowPersian = TimeHelper.unix2PersianTimeAndDate(nowUnix); + const existing = Array.isArray(dic.questions) ? [...dic.questions] : []; + const byAiId = new Map(); + for (const q of existing) { + if (q?.aiItemId) byAiId.set(String(q.aiItemId), q); + } + + const seen = new Set(); + const merged: any[] = []; + + for (const item of exportedItems) { + const aiItemId = String(item.id); + seen.add(aiItemId); + const prev = byAiId.get(aiItemId); + if (prev) { + const prevObj = + prev && typeof (prev as any).toObject === 'function' + ? (prev as any).toObject() + : { ...(prev as any) }; + merged.push({ + ...prevObj, + question: item.q, + answer: item.a, + aiItemId, + deleted: false, + updatedAt: nowPersian, + }); + } else { + merged.push({ + _id: new Types.ObjectId(), + question: item.q, + answer: item.a, + aiItemId, + deleted: false, + createdAt: nowPersian, + createdISO: nowUnix * 1000, + updatedAt: nowPersian, + updatedBy: [], + }); + } + } + + // Soft-delete cache rows that disappeared from AI (only those with aiItemId) + for (const q of existing) { + const aiItemId = q?.aiItemId ? String(q.aiItemId) : ''; + const qObj = + q && typeof (q as any).toObject === 'function' + ? (q as any).toObject() + : { ...(q as any) }; + if (!aiItemId) { + // Legacy row without aiItemId: soft-delete so it cannot be edited as SoT + merged.push({ + ...qObj, + deleted: true, + updatedAt: nowPersian, + }); + continue; + } + if (!seen.has(aiItemId)) { + merged.push({ + ...qObj, + deleted: true, + updatedAt: nowPersian, + }); + } + } + + const $set: Record = { + questions: merged, + aiSyncStatus: 'ok' as AiSyncStatus, + lastSyncedAt: new Date(), + aiCollectionName: collectionName, + aiCollectionId: dic.aiCollectionId || collectionName, + isActive: true, + }; + if (meta) { + $set.description = meta.description; + $set.keywords = meta.keywords; + } + + await this.dictionariesModel.updateOne({ _id: dictionaryId }, { $set }); + } finally { + this.syncing.delete(lockKey); + } + } + + async linkDictionaryToCollection(params: { + dictionaryId: string; + collectionName: string; + }) { + const collectionName = params.collectionName.trim(); + const aiNames = await this.listAiCollectionNames(); + const matched = aiNames.find((n) => n.toLowerCase() === collectionName.toLowerCase()); + if (!matched) { + throw new HttpException('ai_collection_not_found', HttpStatus.NOT_FOUND); + } + + const already = await this.dictionariesModel.findOne({ + _id: { $ne: params.dictionaryId }, + $or: [{ aiCollectionName: matched }, { aiCollectionId: matched }], + }); + if (already) { + throw new HttpException('ai_collection_already_linked', HttpStatus.CONFLICT); + } + + const dic = await this.dictionariesModel.findByIdAndUpdate( + params.dictionaryId, + { + $set: { + aiCollectionName: matched, + aiCollectionId: matched, + aiSyncStatus: 'stale', + }, + }, + { new: true }, + ); + if (!dic) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + + await this.pullCollectionIntoDictionary(String(dic._id), matched); + return this.dictionariesModel.findById(dic._id); + } + + async createDictionaryFromAiCollection(params: { + collectionName: string; + category: string; + type: 'Regulation' | 'Method'; + icon: string; + title?: string; + }) { + const collectionName = params.collectionName.trim(); + const aiNames = await this.listAiCollectionNames(); + const matched = aiNames.find((n) => n.toLowerCase() === collectionName.toLowerCase()); + if (!matched) { + throw new HttpException('ai_collection_not_found', HttpStatus.NOT_FOUND); + } + + const existingLink = await this.dictionariesModel.findOne({ + $or: [{ aiCollectionName: matched }, { aiCollectionId: matched }], + }); + if (existingLink) { + throw new HttpException('ai_collection_already_linked', HttpStatus.CONFLICT); + } + + const nowUnix = Date.now() / 1000; + const nowPersian = TimeHelper.unix2PersianTimeAndDate(nowUnix); + + let meta: { description: string; keywords: string[] } | undefined; + try { + const raw = await this.aiService.getCollectionsWithDescriptions(); + meta = this.parseCollectionsWithDescriptions(raw).get(matched); + } catch { + /* optional */ + } + + const dic = await this.dictionariesModel.create({ + title: params.title || matched, + category: params.category, + type: params.type, + icon: params.icon, + isActive: true, + fileId: [], + questions: [], + aiCollectionId: matched, + aiCollectionName: matched, + description: meta?.description, + keywords: meta?.keywords ?? [], + aiSyncStatus: 'stale', + createdAt: nowPersian, + createdISO: new Date(nowUnix * 1000), + updatedAt: new Date(), + updatedBy: [], + }); + + await this.pullCollectionIntoDictionary(String(dic._id), matched, meta); + return this.dictionariesModel.findById(dic._id); + } + + /** + * After AI single-item mutate succeeds, patch cache; on failure mark stale and rethrow. + */ + async applyCreateToCache(params: { + dictionaryId: string; + question: string; + answer: string; + aiItemId: string; + adminLabel: string; + }) { + const now = Date.now() / 1000; + const nowPersian = TimeHelper.unix2PersianTimeAndDate(now); + const newQuestion = { + _id: new Types.ObjectId(), + question: params.question, + answer: params.answer, + aiItemId: params.aiItemId, + deleted: false, + createdAt: nowPersian, + createdISO: now * 1000, + updatedAt: nowPersian, + updatedBy: [], + }; + + try { + const updated = await this.dictionariesModel.findOneAndUpdate( + { _id: params.dictionaryId }, + { + $push: { questions: newQuestion }, + $set: { lastSyncedAt: new Date(), aiSyncStatus: 'ok' }, + }, + { new: true }, + ); + if (!updated) { + throw new HttpException('dictionary_not_found', HttpStatus.NOT_FOUND); + } + return updated.questions.find( + (q: any) => q.aiItemId === params.aiItemId || q._id.toString() === newQuestion._id.toString(), + ); + } catch (err) { + await this.markStale(params.dictionaryId); + throw err; + } + } + + async applyUpdateToCache(params: { + dictionaryId: string; + questionId: string; + question: string; + answer: string; + adminLabel: string; + }) { + const now = Date.now() / 1000; + try { + const updated = await this.dictionariesModel.findOneAndUpdate( + { + _id: params.dictionaryId, + 'questions._id': new Types.ObjectId(params.questionId), + 'questions.deleted': false, + }, + { + $set: { + 'questions.$[elem].question': params.question, + 'questions.$[elem].answer': params.answer, + 'questions.$[elem].updatedAt': TimeHelper.unix2PersianTimeAndDate(now), + lastSyncedAt: new Date(), + aiSyncStatus: 'ok', + }, + $push: { + 'questions.$[elem].updatedBy': { + name: params.adminLabel, + updatedAt: TimeHelper.unix2PersianTimeAndDate(now), + }, + }, + }, + { + arrayFilters: [{ 'elem._id': new Types.ObjectId(params.questionId) }], + new: true, + }, + ); + if (!updated) { + throw new HttpException('question_not_found', HttpStatus.NOT_FOUND); + } + return updated.questions.find((q) => q._id.toString() === params.questionId); + } catch (err) { + await this.markStale(params.dictionaryId); + throw err; + } + } + + async applyDeleteToCache(params: { dictionaryId: string; questionId: string }) { + const now = Date.now() / 1000; + try { + const updated = await this.dictionariesModel.findOneAndUpdate( + { + _id: params.dictionaryId, + 'questions._id': new Types.ObjectId(params.questionId), + 'questions.deleted': false, + }, + { + $set: { + 'questions.$[elem].deleted': true, + 'questions.$[elem].updatedAt': TimeHelper.unix2PersianTimeAndDate(now), + lastSyncedAt: new Date(), + aiSyncStatus: 'ok', + }, + }, + { + arrayFilters: [{ 'elem._id': new Types.ObjectId(params.questionId) }], + new: true, + }, + ); + if (!updated) { + throw new HttpException('question_not_found', HttpStatus.NOT_FOUND); + } + return updated.questions.find((q) => q._id.toString() === params.questionId); + } catch (err) { + await this.markStale(params.dictionaryId); + throw err; + } + } + + extractAiItemIdFromCreateResponse(data: unknown): string | null { + if (!data || typeof data !== 'object') return null; + const d = data as Record; + const candidates = [d.id, d.item_id, d.itemId, (d.item as any)?.id]; + for (const c of candidates) { + if (typeof c === 'string' && c.trim()) return c.trim(); + } + return null; + } +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/dictionaries/dictionary-file-assets.controller.ts b/src/dictionaries/dictionary-file-assets.controller.ts new file mode 100644 index 0000000..c6f3daa --- /dev/null +++ b/src/dictionaries/dictionary-file-assets.controller.ts @@ -0,0 +1,89 @@ +import { + Body, + Controller, + HttpStatus, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { Types } from 'mongoose'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { AdminDocument } from 'src/database/model/admin.model'; +import { MAX_DICTIONARY_BYTES } from 'src/storage/storage.constants'; +import { DictionaryFileAssetsService } from './dictionary-file-assets.service'; + +class DictionaryAssetUploadBodyDto { + @IsString() + @IsNotEmpty() + category!: string; +} + +@ApiTags('dictionary & question') +@Controller('dictionaries') +@ApiBearerAuth() +@UseGuards(AdminGuard) +@Permissions(Permission.DictionariesAssets) +export class DictionaryFileAssetsController { + constructor(private readonly assetsService: DictionaryFileAssetsService) {} + + @Post('assets') + @ApiOperation({ + summary: + 'Upload dictionary source CSV file to object storage (CSV only); metadata saved in MongoDB.', + }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file', 'category'], + properties: { + file: { + type: 'string', + format: 'binary', + description: 'text/csv only', + }, + category: { + type: 'string', + example: 'lifeInsurance', + description: 'Sanitized category segment for the object key', + }, + }, + }, + }) + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: MAX_DICTIONARY_BYTES }, + }), + ) + async uploadAsset( + @UploadedFile() file: Express.Multer.File, + @Body() body: DictionaryAssetUploadBodyDto, + @AdminIdentity() admin: AdminDocument, + ) { + if (!body?.category?.trim()) { + return new BaseResponseDTO(HttpStatus.BAD_REQUEST, 'category_required', {}); + } + const data = await this.assetsService.upload({ + category: body.category, + adminId: new Types.ObjectId(String(admin._id)), + file, + }); + return new BaseResponseDTO(HttpStatus.CREATED, 'dictionary_asset_uploaded', data); + } +} diff --git a/src/dictionaries/dictionary-file-assets.service.ts b/src/dictionaries/dictionary-file-assets.service.ts new file mode 100644 index 0000000..f32e208 --- /dev/null +++ b/src/dictionaries/dictionary-file-assets.service.ts @@ -0,0 +1,71 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { + DICTIONARY_ALLOWED_MIMES, + MAX_DICTIONARY_BYTES, +} from 'src/storage/storage.constants'; +import { buildDictionaryStorageKey, extensionFromMime } from 'src/storage/key-builder.helper'; +import { StorageService } from 'src/storage/storage.service'; +import { DictionaryFileAssetModel } from 'src/database/model/dictionary-file-asset.model'; + +@Injectable() +export class DictionaryFileAssetsService { + constructor( + private readonly storage: StorageService, + @InjectModel(DictionaryFileAssetModel.name) + private readonly assets: Model, + ) {} + + async upload(params: { + category: string; + adminId: Types.ObjectId; + file: Express.Multer.File; + }) { + const { category, adminId, file } = params; + if (!file?.buffer?.length) { + throw new BadRequestException('file_required'); + } + if (file.size > MAX_DICTIONARY_BYTES) { + throw new BadRequestException('dictionary_file_too_large'); + } + const mime = (file.mimetype || '').toLowerCase().trim(); + if (!DICTIONARY_ALLOWED_MIMES.has(mime)) { + throw new BadRequestException('invalid_dictionary_mime'); + } + + const ext = extensionFromMime(mime); + const key = buildDictionaryStorageKey(category, ext); + + await this.storage.putPrivateObject({ + key, + body: file.buffer, + contentType: mime, + contentLength: file.size, + metadata: { + category: params.category.trim(), + uploadedby: adminId.toHexString(), + }, + }); + + const doc = await this.assets.create({ + category: params.category.trim(), + uploadedBy: adminId, + originalFilename: file.originalname || 'upload', + storageKey: key, + bucket: 'private', + mimeType: mime, + size: file.size, + }); + + return { + id: doc._id.toString(), + category: doc.category, + storageKey: doc.storageKey, + bucket: doc.bucket, + mimeType: doc.mimeType, + size: doc.size, + uploadedAt: doc.get('uploadedAt') as Date, + }; + } +} diff --git a/src/dictionaries/dto/ai-sync.dto.ts b/src/dictionaries/dto/ai-sync.dto.ts new file mode 100644 index 0000000..7006d2b --- /dev/null +++ b/src/dictionaries/dto/ai-sync.dto.ts @@ -0,0 +1,41 @@ +import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class LinkAiCollectionDto { + @ApiProperty({ example: '64f1a2b3c4d5e6f7a8b9c0d1' }) + @IsString() + @IsNotEmpty() + dictionaryId: string; + + @ApiProperty({ example: 'shoab' }) + @IsString() + @IsNotEmpty() + collectionName: string; +} + +export class CreateDictionaryFromAiDto { + @ApiProperty({ example: 'shoab' }) + @IsString() + @IsNotEmpty() + collectionName: string; + + @ApiProperty({ description: 'App category id or enTitle' }) + @IsString() + @IsNotEmpty() + category: string; + + @ApiProperty({ enum: ['Regulation', 'Method'] }) + @IsString() + @IsIn(['Regulation', 'Method']) + type: 'Regulation' | 'Method'; + + @ApiProperty() + @IsString() + @IsNotEmpty() + icon: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + title?: string; +} diff --git a/src/dictionaries/dto/create-dictionary.dto.ts b/src/dictionaries/dto/create-dictionary.dto.ts new file mode 100644 index 0000000..5f8fac7 --- /dev/null +++ b/src/dictionaries/dto/create-dictionary.dto.ts @@ -0,0 +1,150 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { CategoriesEnum } from 'src/common/types/categories.type'; +import { funcsEnum } from 'src/common/types/func.type'; + +export class CreateDictionaryDto { + @ApiProperty({ + required: true, + type: 'array', + description: 'array of ids of the uploaded csv files', + example: ['678e38c62a15cd407a48ccbf', '678e38c62a15cd407a48ccbd'], + }) + fileIds: string[]; + + @ApiProperty({ + required: true, + type: 'string', + enum: funcsEnum, + enumName: 'funcsEnum', + description: 'func to what to do with the dictionary/ create , update , replace', + example: 'create/update/replace', + }) + func: string; + + @ApiProperty({ + required: true, + type: 'string', + enum: CategoriesEnum, + enumName: 'CategoriesEnum', + description: 'category of the uploaded dictionary', + example: 'carInsurance', + }) + category: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'name of the icon picture name', + example: 'carInsurance.png', + }) + icon: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'description of the uploading dictionary', + example: 'توضیحات مربوط به دانشنامه درحال بارگزاری', + }) + description: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'Title of the file to replace (only for func = "replace")', + example: 'responsibility_insurance-15.csv', + }) + fileTitleToReplace?: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'type of the dictionary it could be Regulation | Method', + example: 'Regulation', + }) + type: string; +} + +export class ModificationRequestDto { + @ApiProperty({ + required: true, + type: 'string', + description: 'id of the modification request', + example: '678e38c62a15cd407a48ccbf', + }) + requestId: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'text of old question', + example: '', + }) + oldQuestion: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'text of old answer', + example: '', + }) + oldAnswer: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'text of new question', + example: '', + }) + newQuestion: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'text of new answer', + example: '', + }) + newAnswer: string; + + @ApiProperty({ + required: true, + type: 'boolean', + description: 'status of approved or not. Approve=true , reject=false', + example: 'true', + }) + approved: boolean; + + @ApiProperty({ + required: false, + type: 'string', + description: + 'if there is a reason request is not approved in text format. No need in current version', + example: '', + }) + rejectionReason: boolean; +} + +export class CreateQuestionDto { + @ApiProperty({ + required: true, + type: 'string', + description: 'question string', + example: "این متن سوال است.", + }) + question: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'answer string', + example: "این متن پاسخ است.", + }) + answer: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'id of the dictionary', + example: '678e38c62a15cd407a48ccbf', + }) + dictionaryId: string; +} \ No newline at end of file diff --git a/src/dictionaries/dto/update-dictionary.dto.ts b/src/dictionaries/dto/update-dictionary.dto.ts new file mode 100644 index 0000000..0106dc3 --- /dev/null +++ b/src/dictionaries/dto/update-dictionary.dto.ts @@ -0,0 +1,82 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Types } from 'mongoose'; + +export class UpdateDictionaryDto { + @ApiProperty({ + required: false, + type: 'string', + description: 'title of the dictionary', + example: 'دانشنامه شماره یک ویرایش پنجم', + }) + title: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'name of the category', + example: 'carInsurance', + }) + category: string; + + @ApiProperty({ + required: true, + type: 'string', + description: 'name of the icon picture name', + example: 'carInsurance.png', + }) + icon: string; +} + +export class UpdateQuestionDto { + @ApiProperty({ + required: false, + type: 'string', + description: 'updated question', + example: 'سوال ویرایش شده', + }) + question: string; + + @ApiProperty({ + required: false, + type: 'string', + description: 'updated answer', + example: 'پاسخ ویرایش شده.', + }) + answer: string; +} + +export class QuestionDto { + @ApiProperty({ required: false, type: String }) + _id?: Types.ObjectId; + + @ApiProperty({ + required: true, + type: String, + description: 'The question string', + example: 'Is car insurance mandatory in Iran?', + }) + question: string; + + @ApiProperty({ + required: true, + type: String, + description: 'The answer string', + example: 'Yes, car insurance is mandatory in Iran.', + }) + answer: string; + + @ApiProperty({ required: false, type: Boolean, default: false }) + deleted?: boolean; + + @ApiProperty({ required: false, type: [String], example: ['12:00', '1402-01-01'] }) + createdAt?: [string, string]; + + @ApiProperty({ required: false, type: Number, example: 1672531200000 }) + createdISO?: number; + + @ApiProperty({ required: false, type: Object }) + updatedAt?: { time: string; date: string }; + + @ApiProperty({ required: false, type: [Object] }) + updatedBy?: Array<{ name: string; updatedAt: { time: string; date: string } }>; +} diff --git a/src/expert-prepared-messages/dto/create-expert-prepared-message.dto.ts b/src/expert-prepared-messages/dto/create-expert-prepared-message.dto.ts new file mode 100644 index 0000000..c4f486b --- /dev/null +++ b/src/expert-prepared-messages/dto/create-expert-prepared-message.dto.ts @@ -0,0 +1,59 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + Min, +} from 'class-validator'; +import { + ExpertPreparedMessageCategory, + EXPERT_PREPARED_MESSAGE_CATEGORIES, +} from 'src/common/types/expert-prepared-message-category.type'; + +export class CreateExpertPreparedMessageDto { + @ApiProperty({ + example: 'سلام، چطور می‌تونم کمکتون کنم؟', + description: 'Full text inserted into the chat when the expert selects this snippet', + }) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + message: string; + + @ApiProperty({ + enum: EXPERT_PREPARED_MESSAGE_CATEGORIES, + example: ExpertPreparedMessageCategory.Greetings, + }) + @IsEnum(ExpertPreparedMessageCategory) + category: ExpertPreparedMessageCategory; + + @ApiPropertyOptional({ + example: 'سلام', + description: 'Short label shown on the chip/button in the expert chat UI', + }) + @IsOptional() + @IsString() + @MaxLength(120) + label?: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isEnabled?: boolean; + + @ApiPropertyOptional({ default: 0, description: 'Lower values appear first within a category' }) + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; + + @ApiPropertyOptional({ default: 'fa', example: 'fa' }) + @IsOptional() + @IsString() + @MaxLength(10) + locale?: string; +} diff --git a/src/expert-prepared-messages/dto/expert-prepared-messages-query.dto.ts b/src/expert-prepared-messages/dto/expert-prepared-messages-query.dto.ts new file mode 100644 index 0000000..733455b --- /dev/null +++ b/src/expert-prepared-messages/dto/expert-prepared-messages-query.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { + ExpertPreparedMessageCategory, + EXPERT_PREPARED_MESSAGE_CATEGORIES, +} from 'src/common/types/expert-prepared-message-category.type'; + +export class ExpertPreparedMessagesLocaleQueryDto { + @ApiPropertyOptional({ default: 'fa', example: 'fa' }) + @IsOptional() + @IsString() + @MaxLength(10) + locale?: string; +} + +export class ExpertPreparedMessagesByCategoryQueryDto extends ExpertPreparedMessagesLocaleQueryDto { + @ApiProperty({ + enum: EXPERT_PREPARED_MESSAGE_CATEGORIES, + example: ExpertPreparedMessageCategory.Greetings, + }) + @IsEnum(ExpertPreparedMessageCategory) + category: ExpertPreparedMessageCategory; +} diff --git a/src/expert-prepared-messages/dto/list-expert-prepared-messages-query.dto.ts b/src/expert-prepared-messages/dto/list-expert-prepared-messages-query.dto.ts new file mode 100644 index 0000000..c27a664 --- /dev/null +++ b/src/expert-prepared-messages/dto/list-expert-prepared-messages-query.dto.ts @@ -0,0 +1,30 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; +import { PageOptionsDto } from 'src/common/dto/base-response.dto'; +import { + ExpertPreparedMessageCategory, + EXPERT_PREPARED_MESSAGE_CATEGORIES, +} from 'src/common/types/expert-prepared-message-category.type'; + +export class ListExpertPreparedMessagesQueryDto extends PageOptionsDto { + @ApiPropertyOptional({ enum: EXPERT_PREPARED_MESSAGE_CATEGORIES }) + @IsOptional() + @IsEnum(ExpertPreparedMessageCategory) + category?: ExpertPreparedMessageCategory; + + @ApiPropertyOptional({ description: 'Filter by enabled flag' }) + @IsOptional() + @Transform(({ value }) => { + if (value === 'true' || value === true) return true; + if (value === 'false' || value === false) return false; + return value; + }) + @IsBoolean() + isEnabled?: boolean; + + @ApiPropertyOptional({ example: 'fa' }) + @IsOptional() + @IsString() + locale?: string; +} diff --git a/src/expert-prepared-messages/dto/reorder-expert-prepared-messages.dto.ts b/src/expert-prepared-messages/dto/reorder-expert-prepared-messages.dto.ts new file mode 100644 index 0000000..964240d --- /dev/null +++ b/src/expert-prepared-messages/dto/reorder-expert-prepared-messages.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsInt, + IsMongoId, + IsNotEmpty, + Min, + ValidateNested, +} from 'class-validator'; + +export class ReorderExpertPreparedMessageItemDto { + @ApiProperty({ example: '507f1f77bcf86cd799439011' }) + @IsMongoId() + @IsNotEmpty() + id: string; + + @ApiProperty({ example: 0 }) + @IsInt() + @Min(0) + sortOrder: number; +} + +export class ReorderExpertPreparedMessagesDto { + @ApiProperty({ type: [ReorderExpertPreparedMessageItemDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => ReorderExpertPreparedMessageItemDto) + items: ReorderExpertPreparedMessageItemDto[]; +} diff --git a/src/expert-prepared-messages/dto/update-expert-prepared-message.dto.ts b/src/expert-prepared-messages/dto/update-expert-prepared-message.dto.ts new file mode 100644 index 0000000..9b362f6 --- /dev/null +++ b/src/expert-prepared-messages/dto/update-expert-prepared-message.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateExpertPreparedMessageDto } from './create-expert-prepared-message.dto'; + +export class UpdateExpertPreparedMessageDto extends PartialType( + CreateExpertPreparedMessageDto, +) {} diff --git a/src/expert-prepared-messages/expert-prepared-messages-admin.controller.ts b/src/expert-prepared-messages/expert-prepared-messages-admin.controller.ts new file mode 100644 index 0000000..2118066 --- /dev/null +++ b/src/expert-prepared-messages/expert-prepared-messages-admin.controller.ts @@ -0,0 +1,126 @@ +import { + Body, + Controller, + Delete, + Get, + HttpException, + HttpStatus, + Param, + Patch, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { AdminModel } from 'src/database/model/admin.model'; +import { CreateExpertPreparedMessageDto } from './dto/create-expert-prepared-message.dto'; +import { ListExpertPreparedMessagesQueryDto } from './dto/list-expert-prepared-messages-query.dto'; +import { ReorderExpertPreparedMessagesDto } from './dto/reorder-expert-prepared-messages.dto'; +import { UpdateExpertPreparedMessageDto } from './dto/update-expert-prepared-message.dto'; +import { ExpertPreparedMessagesService } from './expert-prepared-messages.service'; + +@ApiBearerAuth() +@UseGuards(AdminGuard) +@Permissions(Permission.PreparedMessagesManage) +@ApiTags('expert-prepared-messages (admin)') +@Controller('admin/expert-prepared-messages') +export class ExpertPreparedMessagesAdminController { + constructor( + private readonly preparedMessagesService: ExpertPreparedMessagesService, + ) {} + + @Get('categories') + @ApiOperation({ summary: 'List available prepared-message categories' }) + getCategories() { + return { + statusCode: HttpStatus.OK, + message: 'SUCCESS', + data: this.preparedMessagesService.getCategories(), + }; + } + + @Get() + @ApiOperation({ + summary: 'List all prepared messages for expert online chat (admin management)', + }) + findAll(@Query() query: ListExpertPreparedMessagesQueryDto) { + return this.preparedMessagesService.findAllForAdmin(query); + } + + @Post() + @ApiOperation({ summary: 'Create a prepared message snippet' }) + create( + @Body() dto: CreateExpertPreparedMessageDto, + @AdminIdentity() admin: AdminModel, + ) { + return this.preparedMessagesService.create(dto, admin.username); + } + + @Put(':id') + @ApiOperation({ summary: 'Update a prepared message snippet' }) + @ApiParam({ name: 'id' }) + update( + @Param('id') id: string, + @Body() dto: UpdateExpertPreparedMessageDto, + @AdminIdentity() admin: AdminModel, + ) { + try { + return this.preparedMessagesService.update(id, dto, admin.username); + } catch (error) { + if (error instanceof HttpException) throw error; + throw new HttpException( + error.message || 'Failed to update prepared message', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Patch('reorder') + @ApiOperation({ summary: 'Bulk update sort order for prepared messages' }) + reorder( + @Body() dto: ReorderExpertPreparedMessagesDto, + @AdminIdentity() admin: AdminModel, + ) { + return this.preparedMessagesService.reorder(dto, admin.username); + } + + @Patch(':id/toggle') + @ApiOperation({ summary: 'Enable or disable a prepared message snippet' }) + @ApiParam({ name: 'id' }) + toggle(@Param('id') id: string, @AdminIdentity() admin: AdminModel) { + try { + return this.preparedMessagesService.toggleEnabled(id, admin.username); + } catch (error) { + if (error instanceof HttpException) throw error; + throw new HttpException( + error.message || 'Failed to toggle prepared message', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a prepared message snippet' }) + @ApiParam({ name: 'id' }) + remove(@Param('id') id: string) { + try { + return this.preparedMessagesService.remove(id); + } catch (error) { + if (error instanceof HttpException) throw error; + throw new HttpException( + error.message || 'Failed to delete prepared message', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/expert-prepared-messages/expert-prepared-messages-expert.controller.ts b/src/expert-prepared-messages/expert-prepared-messages-expert.controller.ts new file mode 100644 index 0000000..fb53aa2 --- /dev/null +++ b/src/expert-prepared-messages/expert-prepared-messages-expert.controller.ts @@ -0,0 +1,72 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { + ExpertPreparedMessagesByCategoryQueryDto, + ExpertPreparedMessagesLocaleQueryDto, +} from './dto/expert-prepared-messages-query.dto'; +import { ExpertPreparedMessagesService } from './expert-prepared-messages.service'; + +@ApiBearerAuth() +@UseGuards(AdminGuard) +@Permissions(Permission.PreparedMessagesRead) +@ApiTags('expert-prepared-messages (expert)') +@Controller('expert/prepared-messages') +export class ExpertPreparedMessagesExpertController { + constructor( + private readonly preparedMessagesService: ExpertPreparedMessagesService, + ) {} + + @Get('categories') + @ApiOperation({ + summary: + 'List prepared-message categories that have at least one enabled message', + }) + @ApiQuery({ + name: 'locale', + required: false, + example: 'fa', + description: 'Locale filter (defaults to fa)', + }) + findCategories(@Query() query: ExpertPreparedMessagesLocaleQueryDto) { + return this.preparedMessagesService.findEnabledCategoriesForExpert( + query.locale || 'fa', + ); + } + + @Get() + @ApiOperation({ + summary: 'Get enabled prepared messages for a specific category', + }) + @ApiQuery({ + name: 'category', + required: true, + enum: [ + 'greetings', + 'quick_answers', + 'closing', + 'follow_up', + 'apology', + 'other', + ], + }) + @ApiQuery({ + name: 'locale', + required: false, + example: 'fa', + description: 'Locale filter (defaults to fa)', + }) + findByCategory(@Query() query: ExpertPreparedMessagesByCategoryQueryDto) { + return this.preparedMessagesService.findEnabledByCategoryForExpert( + query.category, + query.locale || 'fa', + ); + } +} diff --git a/src/expert-prepared-messages/expert-prepared-messages.module.ts b/src/expert-prepared-messages/expert-prepared-messages.module.ts new file mode 100644 index 0000000..598fbde --- /dev/null +++ b/src/expert-prepared-messages/expert-prepared-messages.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { DatabaseModule } from 'src/database/database.module'; +import { + ExpertPreparedMessageModel, + ExpertPreparedMessageSchema, +} from 'src/database/model/expert-prepared-message.model'; +import { ExpertPreparedMessagesAdminController } from './expert-prepared-messages-admin.controller'; +import { ExpertPreparedMessagesExpertController } from './expert-prepared-messages-expert.controller'; +import { ExpertPreparedMessagesService } from './expert-prepared-messages.service'; + +@Module({ + imports: [ + DatabaseModule, + MongooseModule.forFeature([ + { + name: ExpertPreparedMessageModel.name, + schema: ExpertPreparedMessageSchema, + }, + ]), + ], + controllers: [ + ExpertPreparedMessagesAdminController, + ExpertPreparedMessagesExpertController, + ], + providers: [ExpertPreparedMessagesService], + exports: [ExpertPreparedMessagesService], +}) +export class ExpertPreparedMessagesModule {} diff --git a/src/expert-prepared-messages/expert-prepared-messages.service.ts b/src/expert-prepared-messages/expert-prepared-messages.service.ts new file mode 100644 index 0000000..65f84a4 --- /dev/null +++ b/src/expert-prepared-messages/expert-prepared-messages.service.ts @@ -0,0 +1,263 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { FilterQuery, Model } from 'mongoose'; +import { + BaseResponseDTO, + PageMetaDto, +} from 'src/common/dto/base-response.dto'; +import { + EXPERT_PREPARED_MESSAGE_CATEGORIES, + EXPERT_PREPARED_MESSAGE_CATEGORY_LABELS, + ExpertPreparedMessageCategory, +} from 'src/common/types/expert-prepared-message-category.type'; +import { ExpertPreparedMessageModel } from 'src/database/model/expert-prepared-message.model'; +import { CreateExpertPreparedMessageDto } from './dto/create-expert-prepared-message.dto'; +import { ListExpertPreparedMessagesQueryDto } from './dto/list-expert-prepared-messages-query.dto'; +import { ReorderExpertPreparedMessagesDto } from './dto/reorder-expert-prepared-messages.dto'; +import { UpdateExpertPreparedMessageDto } from './dto/update-expert-prepared-message.dto'; + +export type ExpertPreparedMessageItem = { + id: string; + message: string; + label: string; + category: ExpertPreparedMessageCategory; + sortOrder: number; + locale: string; +}; + +export type ExpertPreparedMessageCategorySummary = { + value: ExpertPreparedMessageCategory; + label: string; + messageCount: number; +}; + +@Injectable() +export class ExpertPreparedMessagesService { + constructor( + @InjectModel(ExpertPreparedMessageModel.name) + private readonly preparedMessageModel: Model, + ) {} + + getCategories() { + return EXPERT_PREPARED_MESSAGE_CATEGORIES.map((value) => ({ + value, + label: EXPERT_PREPARED_MESSAGE_CATEGORY_LABELS[value], + })); + } + + async findAllForAdmin(query: ListExpertPreparedMessagesQueryDto) { + const filter: FilterQuery = {}; + if (query.category) filter.category = query.category; + if (query.isEnabled !== undefined) filter.isEnabled = query.isEnabled; + if (query.locale) filter.locale = query.locale; + + const [items, itemCount] = await Promise.all([ + this.preparedMessageModel + .find(filter) + .sort({ category: 1, sortOrder: 1, createdAt: 1 }) + .skip(query.skip) + .limit(query.take) + .lean() + .exec(), + this.preparedMessageModel.countDocuments(filter).exec(), + ]); + + const meta = new PageMetaDto({ pageOptionsDto: query, itemCount }); + return new BaseResponseDTO( + HttpStatus.OK, + 'Expert prepared messages retrieved successfully', + items.map((doc) => this.toAdminDto(doc)), + meta, + ); + } + + async findEnabledCategoriesForExpert(locale = 'fa') { + const counts = await this.preparedMessageModel + .aggregate<{ _id: ExpertPreparedMessageCategory; count: number }>([ + { $match: { isEnabled: true, locale } }, + { $group: { _id: '$category', count: { $sum: 1 } } }, + ]) + .exec(); + + const countByCategory = new Map( + counts.map((row) => [row._id, row.count]), + ); + + const categories: ExpertPreparedMessageCategorySummary[] = + EXPERT_PREPARED_MESSAGE_CATEGORIES.filter((value) => + countByCategory.has(value), + ).map((value) => ({ + value, + label: EXPERT_PREPARED_MESSAGE_CATEGORY_LABELS[value], + messageCount: countByCategory.get(value) ?? 0, + })); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + locale, + categories, + }); + } + + async findEnabledByCategoryForExpert( + category: ExpertPreparedMessageCategory, + locale = 'fa', + ) { + const docs = await this.preparedMessageModel + .find({ isEnabled: true, locale, category }) + .sort({ sortOrder: 1, createdAt: 1 }) + .lean() + .exec(); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + locale, + category, + label: EXPERT_PREPARED_MESSAGE_CATEGORY_LABELS[category], + messages: docs.map((doc) => this.toExpertItem(doc)), + }); + } + + async create(dto: CreateExpertPreparedMessageDto, adminUsername: string) { + const sortOrder = + dto.sortOrder ?? + (await this.nextSortOrderForCategory(dto.category, dto.locale ?? 'fa')); + + const created = await this.preparedMessageModel.create({ + message: dto.message.trim(), + category: dto.category, + label: (dto.label ?? '').trim(), + isEnabled: dto.isEnabled ?? true, + sortOrder, + locale: (dto.locale ?? 'fa').trim(), + createdBy: adminUsername, + updatedBy: adminUsername, + }); + + return new BaseResponseDTO( + HttpStatus.CREATED, + 'Expert prepared message created successfully', + this.toAdminDto(created.toObject()), + ); + } + + async update( + id: string, + dto: UpdateExpertPreparedMessageDto, + adminUsername: string, + ) { + const existing = await this.preparedMessageModel.findById(id).exec(); + if (!existing) { + throw new HttpException( + 'prepared_message_not_found', + HttpStatus.NOT_FOUND, + ); + } + + if (dto.message !== undefined) existing.message = dto.message.trim(); + if (dto.category !== undefined) existing.category = dto.category; + if (dto.label !== undefined) existing.label = dto.label.trim(); + if (dto.isEnabled !== undefined) existing.isEnabled = dto.isEnabled; + if (dto.sortOrder !== undefined) existing.sortOrder = dto.sortOrder; + if (dto.locale !== undefined) existing.locale = dto.locale.trim(); + existing.updatedBy = adminUsername; + + const saved = await existing.save(); + return new BaseResponseDTO( + HttpStatus.OK, + 'Expert prepared message updated successfully', + this.toAdminDto(saved.toObject()), + ); + } + + async toggleEnabled(id: string, adminUsername: string) { + const existing = await this.preparedMessageModel.findById(id).exec(); + if (!existing) { + throw new HttpException( + 'prepared_message_not_found', + HttpStatus.NOT_FOUND, + ); + } + + existing.isEnabled = !existing.isEnabled; + existing.updatedBy = adminUsername; + const saved = await existing.save(); + + return new BaseResponseDTO( + HttpStatus.OK, + 'Expert prepared message toggled successfully', + this.toAdminDto(saved.toObject()), + ); + } + + async reorder(dto: ReorderExpertPreparedMessagesDto, adminUsername: string) { + const bulk = dto.items.map((item) => ({ + updateOne: { + filter: { _id: item.id }, + update: { $set: { sortOrder: item.sortOrder, updatedBy: adminUsername } }, + }, + })); + + await this.preparedMessageModel.bulkWrite(bulk); + + return new BaseResponseDTO( + HttpStatus.OK, + 'Expert prepared messages reordered successfully', + null, + ); + } + + async remove(id: string) { + const result = await this.preparedMessageModel.findByIdAndDelete(id).exec(); + if (!result) { + throw new HttpException( + 'prepared_message_not_found', + HttpStatus.NOT_FOUND, + ); + } + + return new BaseResponseDTO( + HttpStatus.OK, + 'Expert prepared message deleted successfully', + null, + ); + } + + private async nextSortOrderForCategory( + category: ExpertPreparedMessageCategory, + locale: string, + ): Promise { + const last = await this.preparedMessageModel + .findOne({ category, locale }) + .sort({ sortOrder: -1 }) + .select({ sortOrder: 1 }) + .lean() + .exec(); + return (last?.sortOrder ?? -1) + 1; + } + + private toExpertItem(doc: any): ExpertPreparedMessageItem { + return { + id: String(doc._id), + message: doc.message, + label: doc.label || doc.message.slice(0, 40), + category: doc.category, + sortOrder: doc.sortOrder ?? 0, + locale: doc.locale ?? 'fa', + }; + } + + private toAdminDto(doc: any) { + return { + id: String(doc._id), + message: doc.message, + label: doc.label ?? '', + category: doc.category, + isEnabled: doc.isEnabled, + sortOrder: doc.sortOrder ?? 0, + locale: doc.locale ?? 'fa', + createdBy: doc.createdBy ?? null, + updatedBy: doc.updatedBy ?? null, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; + } +} diff --git a/src/externals/inquiries/inquiries.module.ts b/src/externals/inquiries/inquiries.module.ts new file mode 100644 index 0000000..74e12ca --- /dev/null +++ b/src/externals/inquiries/inquiries.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { InquiriesService } from './inquiries.service'; + +@Module({ + providers: [InquiriesService], +}) +export class InquiriesModule {} diff --git a/src/externals/inquiries/inquiries.service.ts b/src/externals/inquiries/inquiries.service.ts new file mode 100644 index 0000000..c080f2b --- /dev/null +++ b/src/externals/inquiries/inquiries.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class InquiriesService {} diff --git a/src/externals/saman-insurance/dto/installment-response.dto.ts b/src/externals/saman-insurance/dto/installment-response.dto.ts new file mode 100644 index 0000000..2bf6b75 --- /dev/null +++ b/src/externals/saman-insurance/dto/installment-response.dto.ts @@ -0,0 +1,25 @@ +export interface InstallmentDto { + policyId: number; + paymentMethod: string; + installmentId: string; + installmentYear: string; + installmentNumber: number; + installmentPaymentAmount: number; + remainingAmount: number; + installmentDate: string; + installmentPaymentDate?: string; + installmentStatus: string; + totalInstallments: number; + paidInstallments: number; + upcomingInstallmentCount: number; + overdueInstallmentCount: number; + bankId: string; + totalPremiumCollected: number; +} + +export interface InstallmentsResponseDto { + policyHolderInstallments: InstallmentDto[]; + errors: Record; +} + +// Made with Bob diff --git a/src/externals/saman-insurance/dto/policy-response.dto.ts b/src/externals/saman-insurance/dto/policy-response.dto.ts new file mode 100644 index 0000000..ad54ebb --- /dev/null +++ b/src/externals/saman-insurance/dto/policy-response.dto.ts @@ -0,0 +1,39 @@ +export interface PolicyDto { + insuredName: string; + policyId: number; + policyNumber: string; + insuranceLineName: string; + insuranceLineCode: number; + policyHolderName: string; + policyIssueDate: string; + policyBeginDate: string; + policyEndDate: string; + referrerName: string; + issuingUnit: string; + uniquePolicyCode: string; + policyStatusCode: number; + policyStatus: string; + policyHolderCode: string; + agentCode: number; +} + +export interface PoliciesResponseDto { + fireInsurancePolicies: PolicyDto[]; + carInsurancePolicies: PolicyDto[]; + healthInsurancePolicies: PolicyDto[]; + cargoInsurancePolicies: PolicyDto[]; + equipmentInsurancePolicies: PolicyDto[]; + lifeInsurancePolicies: PolicyDto[]; + travelInsurancePolicies: PolicyDto[]; + liabilityInsurancePolicies: PolicyDto[]; + personalAccidentInsurancePolicies: PolicyDto[]; + errors: Record; +} + +export interface TokenResponseDto { + userName: string; + token: string; + expirtTime: string; // Note: API has typo "expirtTime" instead of "expireTime" +} + +// Made with Bob diff --git a/src/externals/saman-insurance/saman-insurance.module.ts b/src/externals/saman-insurance/saman-insurance.module.ts new file mode 100644 index 0000000..ec634bb --- /dev/null +++ b/src/externals/saman-insurance/saman-insurance.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { SamanInsuranceService } from './saman-insurance.service'; +import { + ExternalTokensModel, + ExternalTokenSchema, +} from 'src/database/model/externalTokens.model'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: ExternalTokensModel.name, schema: ExternalTokenSchema }, + ]), + ], + providers: [SamanInsuranceService], + exports: [SamanInsuranceService], +}) +export class SamanInsuranceModule {} + +// Made with Bob diff --git a/src/externals/saman-insurance/saman-insurance.service.ts b/src/externals/saman-insurance/saman-insurance.service.ts new file mode 100644 index 0000000..59f8346 --- /dev/null +++ b/src/externals/saman-insurance/saman-insurance.service.ts @@ -0,0 +1,381 @@ +import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import axios, { AxiosInstance } from 'axios'; +import { ExternalTokensModel } from 'src/database/model/externalTokens.model'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { + PoliciesResponseDto, + TokenResponseDto, +} from './dto/policy-response.dto'; +import { InstallmentsResponseDto } from './dto/installment-response.dto'; + +@Injectable() +export class SamanInsuranceService { + private readonly logger = new Logger(SamanInsuranceService.name); + private readonly axiosInstance: AxiosInstance; + private readonly baseUrl = + process.env.SAMAN_API_BASE_URL || 'https://tsisapi.si24.ir/api/v1'; + private readonly tokenScope = 'saman-insurance-api'; + private readonly tokenBufferMs = + parseInt(process.env.SAMAN_API_TOKEN_BUFFER_MINUTES || '5', 10) * + 60 * + 1000; + + /** In-memory cache so all requests share one token without hitting DB every time */ + private memoryCache: { token: string; expiresAtMs: number } | null = null; + /** Prevents concurrent logins from requesting multiple tokens at once */ + private refreshPromise: Promise | null = null; + + constructor( + @InjectModel(ExternalTokensModel.name) + private readonly externalTokens: Model, + ) { + this.axiosInstance = axios.create({ + baseURL: this.baseUrl, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + /** + * Returns a shared app-level token reused until near expiry. + * This is NOT per-user — one token serves all policy/installment requests. + */ + async getValidToken(): Promise { + if (this.isTokenUsable(this.memoryCache)) { + this.logger.debug('Using in-memory cached Saman insurance API token'); + return this.memoryCache.token; + } + + const stored = await this.findValidStoredToken(); + if (stored) { + this.memoryCache = stored; + this.logger.log('Using stored Saman insurance API token from externalTokens'); + return stored.token; + } + + return this.refreshToken(); + } + + private isTokenUsable( + cache: { token: string; expiresAtMs: number } | null, + ): cache is { token: string; expiresAtMs: number } { + return ( + Boolean(cache?.token) && + cache.expiresAtMs - Date.now() > this.tokenBufferMs + ); + } + + private resolveExpiryMs( + expirtTime?: string, + issuedAtUnix?: number, + ): number { + if (expirtTime) { + const numeric = Number(expirtTime); + if (Number.isFinite(numeric) && numeric > 1_000_000_000) { + return numeric > 1_000_000_000_000 ? numeric : numeric * 1000; + } + + const parsed = Date.parse(expirtTime); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + + const issuedMs = (issuedAtUnix ?? Math.floor(Date.now() / 1000)) * 1000; + const fallbackHours = parseInt( + process.env.SAMAN_API_TOKEN_FALLBACK_TTL_HOURS || '23', + 10, + ); + return issuedMs + fallbackHours * 60 * 60 * 1000; + } + + private async findValidStoredToken(): Promise<{ + token: string; + expiresAtMs: number; + } | null> { + const existingToken = await this.externalTokens + .findOne({ + scope: this.tokenScope, + url: this.baseUrl, + isActive: true, + }) + .sort({ timestamps: -1 }) + .lean() + .exec(); + + if (!existingToken?.token) { + return null; + } + + const expiresAtMs = this.resolveExpiryMs( + existingToken.expiresIn, + Number(existingToken.timestamps), + ); + + if (expiresAtMs - Date.now() <= this.tokenBufferMs) { + this.logger.log( + 'Stored Saman insurance API token expired or near expiry, will refresh', + ); + await this.externalTokens.updateOne( + { _id: existingToken._id }, + { $set: { isActive: false } }, + ); + return null; + } + + return { + token: existingToken.token, + expiresAtMs, + }; + } + + private async refreshToken(): Promise { + if (this.refreshPromise) { + this.logger.debug( + 'Waiting for in-flight Saman insurance API token refresh', + ); + return this.refreshPromise; + } + + this.refreshPromise = this.fetchAndStoreToken().finally(() => { + this.refreshPromise = null; + }); + + return this.refreshPromise; + } + + private async invalidateStoredTokens(): Promise { + this.memoryCache = null; + await this.externalTokens.updateMany( + { scope: this.tokenScope, url: this.baseUrl }, + { $set: { isActive: false } }, + ); + } + + private async fetchAndStoreToken(): Promise { + const username = process.env.SAMAN_API_USERNAME || 'ChatbotTest'; + const password = process.env.SAMAN_API_PASSWORD || 'C@vn6ym9gXzqAj5gbRe'; + + this.logger.log('Fetching new shared Saman insurance API token'); + + const response = await this.axiosInstance.get( + '/users/authenticate/', + { + params: { username, password }, + }, + ); + + if (!response.data?.token) { + throw new Error('Invalid token response from Saman API'); + } + + const { token, expirtTime } = response.data; + const now = Math.floor(Date.now() / 1000); + const expiresAtMs = this.resolveExpiryMs(expirtTime, now); + + await this.invalidateStoredTokens(); + + await this.externalTokens.create({ + token, + url: this.baseUrl, + isActive: true, + method: 'access', + tokenType: 'Bearer', + expiresIn: expirtTime, + scope: this.tokenScope, + timestamps: now, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + updatedAt: TimeHelper.unix2PersianTimeAndDate(now), + }); + + this.memoryCache = { token, expiresAtMs }; + this.logger.log( + `New Saman insurance API token stored (scope=${this.tokenScope}, expires~${new Date(expiresAtMs).toISOString()})`, + ); + + return token; + } + + private async withAuthorizedRequest( + operation: (token: string) => Promise, + ): Promise { + const token = await this.getValidToken(); + + try { + return await operation(token); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + this.logger.warn( + 'Saman insurance API token rejected with 401, refreshing once', + ); + await this.invalidateStoredTokens(); + const freshToken = await this.refreshToken(); + return operation(freshToken); + } + throw error; + } + } + + async fetchPolicies(nationalCode: string): Promise { + try { + this.logger.log(`Fetching policies for national code: ${nationalCode}`); + + const response = await this.withAuthorizedRequest((token) => + this.axiosInstance.get( + '/Core/policyholder/List-Of-Policies', + { + params: { NationalCode: nationalCode }, + headers: { Authorization: `Bearer ${token}` }, + }, + ), + ); + + if (!response.data) { + throw new Error('Invalid response from Saman API'); + } + + this.logger.log( + `Successfully fetched policies for national code: ${nationalCode}`, + ); + return response.data; + } catch (error) { + this.logger.error('Error fetching policies:', error); + throw new HttpException( + 'Failed to fetch policies from Saman API', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async fetchInstallments(policyId: number): Promise { + try { + this.logger.log(`Fetching installments for policy ID: ${policyId}`); + + const response = await this.withAuthorizedRequest((token) => + this.axiosInstance.get( + '/Core/policyholder/List-Of-Installments', + { + params: { PolicyId: policyId }, + headers: { Authorization: `Bearer ${token}` }, + }, + ), + ); + + if (!response.data) { + throw new Error('Invalid response from Saman API'); + } + + this.logger.log( + `Successfully fetched installments for policy ID: ${policyId}`, + ); + return response.data; + } catch (error) { + this.logger.error( + `Error fetching installments for policy ${policyId}:`, + error, + ); + throw new HttpException( + `Failed to fetch installments for policy ${policyId}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async fetchPoliciesWithInstallments( + nationalCode: string, + ): Promise<{ policies: any[]; installments: any[] }> { + try { + this.logger.log( + `Fetching all policies with installments for national code: ${nationalCode}`, + ); + + const policiesResponse = await this.fetchPolicies(nationalCode); + + const allPolicies = [ + ...policiesResponse.fireInsurancePolicies.map((p) => ({ + ...p, + policyType: 'fire', + })), + ...policiesResponse.carInsurancePolicies.map((p) => ({ + ...p, + policyType: 'car', + })), + ...policiesResponse.healthInsurancePolicies.map((p) => ({ + ...p, + policyType: 'health', + })), + ...policiesResponse.cargoInsurancePolicies.map((p) => ({ + ...p, + policyType: 'cargo', + })), + ...policiesResponse.equipmentInsurancePolicies.map((p) => ({ + ...p, + policyType: 'equipment', + })), + ...policiesResponse.lifeInsurancePolicies.map((p) => ({ + ...p, + policyType: 'life', + })), + ...policiesResponse.travelInsurancePolicies.map((p) => ({ + ...p, + policyType: 'travel', + })), + ...policiesResponse.liabilityInsurancePolicies.map((p) => ({ + ...p, + policyType: 'liability', + })), + ...policiesResponse.personalAccidentInsurancePolicies.map((p) => ({ + ...p, + policyType: 'personalAccident', + })), + ]; + + this.logger.log(`Found ${allPolicies.length} policies`); + + const allInstallments = []; + + for (const policy of allPolicies) { + try { + const installmentsResponse = await this.withAuthorizedRequest( + (authToken) => + this.axiosInstance.get( + '/Core/policyholder/List-Of-Installments', + { + params: { PolicyId: policy.policyId }, + headers: { Authorization: `Bearer ${authToken}` }, + }, + ), + ); + + if ( + installmentsResponse.data?.policyHolderInstallments?.length > 0 + ) { + allInstallments.push( + ...installmentsResponse.data.policyHolderInstallments, + ); + } + } catch (error) { + this.logger.warn( + `Failed to fetch installments for policy ${policy.policyId}, continuing...`, + ); + } + } + + this.logger.log( + `Successfully fetched ${allPolicies.length} policies and ${allInstallments.length} installments using one shared token`, + ); + + return { + policies: allPolicies, + installments: allInstallments, + }; + } catch (error) { + this.logger.error('Error fetching policies with installments:', error); + throw error; + } + } +} diff --git a/src/externals/sms/saman.service.ts b/src/externals/sms/saman.service.ts new file mode 100644 index 0000000..e18c153 --- /dev/null +++ b/src/externals/sms/saman.service.ts @@ -0,0 +1,90 @@ +import { HttpService } from '@nestjs/axios'; +import { + HttpException, + HttpStatus, + Injectable, + OnModuleInit, +} from '@nestjs/common'; +import axios from 'axios'; +import qs from 'qs'; +import { lastValueFrom } from 'rxjs'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; + +@Injectable() +export class SamanSmsService implements OnModuleInit { + private authToken: string; + private apiKeyKavenegar: any; + private serviceUrl: string = process.env.SAMAN_AUTH_URL; + private headers: { + 'Content-Type': 'application/x-www-form-urlencoded'; + }; + constructor(private readonly httpService: HttpService) {} + async onModuleInit() { + let smsResponse = this.httpService.post( + `https://api.kavenegar.com/v1/${process.env.KavenegarApiKey}/sms/send.json?receptor=${'09226187419'}&sender=${process.env.KVN_sender}&message=${'SMS_CONNECTED'}`, + ); + } + + private async samanNotifyLogin() { + try { + let sendRequest = this.httpService.post(this.serviceUrl, { + headers: this.headers, + data: qs.stringify({ + grant_type: process.env.GrantType, + client_id: process.env.ClientId, + client_secret: process.env.ClientSecret, + }), + }); + //@ts-ignore + let response = await lastValueFrom(sendRequest); + return response.data['access_token']; + } catch (err) { + return new HttpException(err.message, err.status); + } + } + + public async notifyService(mobile, message, type) { + try { + let data = JSON.stringify({ + phoneNumber: mobile, + body: message, + type: type, + }); + let axiosConfig = { + method: 'post', + maxBodyLength: Infinity, + url: process.env.NOTIFY_URL, + headers: { + 'request-id': process.env.NOTIFY_REQUEST_ID, + 'Content-Type': 'application/json', + Authorization: '', //todo + }, + data: data, + }; + const response = await axios.request(axiosConfig); + if (!response.data) + throw new HttpException('notify_unavailable', HttpStatus.BAD_REQUEST); + + return true; //todo + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async smsSender(msg: string, receptor: string) { + try { + let url = `https://api.kavenegar.com/v1/${process.env.KavenegarApiKey}/verify/lookup.json?receptor=${receptor}&token=${msg}&template=${process.env.TEMPLATE}`; + const response = await lastValueFrom(this.httpService.post(url)); + return response.data; + } catch (err) { + console.error('Kavenegar API Error:', err.response?.data || err.message); + + // Extract the error details from Kavenegar's response (if available) + const errorMessage = err.response?.data?.message || err.message; + const errorStatus = err.response?.status || 500; + + throw new HttpException(errorMessage, errorStatus); + } + } +} diff --git a/src/externals/sms/sms.module.ts b/src/externals/sms/sms.module.ts new file mode 100644 index 0000000..642351e --- /dev/null +++ b/src/externals/sms/sms.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { SamanSmsService } from './saman.service'; +import { SmsService } from './sms.service'; + +@Module({ + imports: [DatabaseModule], + providers: [SmsService, SamanSmsService], + exports: [SamanSmsService], +}) +export class SmsModule {} diff --git a/src/externals/sms/sms.service.ts b/src/externals/sms/sms.service.ts new file mode 100644 index 0000000..bc85c27 --- /dev/null +++ b/src/externals/sms/sms.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class SmsService {} diff --git a/src/externals/sso/sso.module.ts b/src/externals/sso/sso.module.ts new file mode 100644 index 0000000..79b8bc9 --- /dev/null +++ b/src/externals/sso/sso.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { SsoService } from './sso.service'; + +@Module({ + imports: [DatabaseModule], + providers: [SsoService], + exports: [SsoService], +}) +export class SsoModule {} diff --git a/src/externals/sso/sso.service.ts b/src/externals/sso/sso.service.ts new file mode 100644 index 0000000..fc91f3e --- /dev/null +++ b/src/externals/sso/sso.service.ts @@ -0,0 +1,149 @@ +import { HttpService } from '@nestjs/axios'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import axios from 'axios'; +import { Model } from 'mongoose'; +import { lastValueFrom } from 'rxjs'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { ExternalTokensModel } from 'src/database/model/externalTokens.model'; + +@Injectable() +export class SsoService { + private serviceUrl: string = process.env.SAMAN_AUTH_URL; + private readonly notifyTokenScope = 'notif-sms'; + private headers: { + 'Content-Type': 'application/x-www-form-urlencoded'; + }; + constructor( + @InjectModel(ExternalTokensModel.name) + private readonly externalTokens: Model, + private readonly httpService: HttpService, + ) {} + + private async samanNotifyLogin() { + try { + const now = Math.floor(Date.now() / 1000); // Current time in seconds + const params = new URLSearchParams({ + grant_type: process.env.GrantType, + client_id: process.env.ClientId, + client_secret: process.env.ClientSecret, + }); + + // Find the most recent active token by sorting in descending order + const token = await this.externalTokens + .findOne({ + scope: this.notifyTokenScope, + isActive: true, + }) + .sort({ timestamps: -1 }); // Get the most recent token + // Check if token exists and is not expired + if (token && token.timestamps && token.expiresIn) { + const tokenTimestamp = Number(token.timestamps); + const expiresInSeconds = Number(token.expiresIn); + + const expirationTime = tokenTimestamp + expiresInSeconds; + + // If token is still valid, return it + if (now < expirationTime) { + return token.token; + } + + // If token is expired, mark it as inactive + await this.externalTokens.updateOne( + { _id: token._id }, + { isActive: false }, + ); + } + + // Request new token + const sendRequest = this.httpService.post( + this.serviceUrl, + params.toString(), + { + headers: { + ...this.headers, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ); + + //@ts-ignore + const response = await lastValueFrom(sendRequest); + + // Create new token record + const newToken = await this.externalTokens.create({ + token: response.data.access_token, + url: this.serviceUrl, + isActive: true, + method: 'access', + tokenType: 'Bearer', + expiresIn: response.data.expires_in, + scope: this.notifyTokenScope, + createdAt: TimeHelper.unix2PersianTimeAndDate(now), + createdISO: Date.now(), + updatedAt: TimeHelper.unix2PersianTimeAndDate(now), + timestamps: now, + }); + + return response.data.access_token; + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + public async notifyService(mobile, message, type) { + try { + const accessToken = await this.samanNotifyLogin(); + let data = JSON.stringify({ + phoneNumber: mobile, + body: message, + type: type, + }); + let axiosConfig = { + method: 'post', + maxBodyLength: Infinity, + url: process.env.NOTIFY_URL, + headers: { + 'request-id': process.env.NOTIFY_REQUEST_ID, + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + data: data, + }; + const response = await axios.request(axiosConfig); + if (!response.data) + throw new HttpException('notify_unavailable', HttpStatus.BAD_REQUEST); + + return true; //todo + } catch (err) { + console.error(err); + + // Handle Axios errors + if (err.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + throw new BaseResponseDTO( + err.response.status, + err.response.data.message || 'notify_failed', + null, + ); + } else if (err.request) { + // The request was made but no response was received + throw new BaseResponseDTO( + HttpStatus.SERVICE_UNAVAILABLE, + 'notify_service_unavailable', + null, + ); + } else { + // Something happened in setting up the request that triggered an error + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'internal_server_error', + null, + ); + } + } + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..abd06ca --- /dev/null +++ b/src/main.ts @@ -0,0 +1,140 @@ +import { join } from 'node:path'; +import { + ValidationPipe, + VersioningType, + VERSION_NEUTRAL, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpAdapterHost, NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { Request, Response, NextFunction } from 'express'; +import * as express from 'express'; +import { AppModule } from './app.module'; +import { SecurityHeadersMiddleware } from './common/middlewares/security-headers.middleware'; +import { SwaggerAuthMiddleware } from './common/middlewares/swagger-auth.middleware'; +import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { + bodyParser: false, + }); + const configService = app.get(ConfigService); + const requestBodyLimit = configService.get('REQUEST_BODY_LIMIT', '10mb'); + + app.useBodyParser('json', { limit: requestBodyLimit }); + app.useBodyParser('urlencoded', { + limit: requestBodyLimit, + extended: true, + }); + + const now = new Date(); + console.log('Server started at:', now.toISOString()); + console.log('Local time:', now.toLocaleString()); + // Get allowed hosts from environment variables (no fallback) + const allowedHosts = configService.get('ALLOWED_HOSTS'); + if (!allowedHosts) { + throw new Error('ALLOWED_HOSTS environment variable not configured'); + } + + // Get CORS origins from environment variables (no fallback) + const corsOrigins = configService.get('CORS_ORIGINS'); + if (!corsOrigins) { + throw new Error('CORS_ORIGINS environment variable not configured'); + } + + // Host Validation Middleware + const hostValidationMiddleware = ( + req: Request, + res: Response, + next: NextFunction, + ) => { + const hostHeader = req.headers.host; + if (!hostHeader || !allowedHosts.includes(hostHeader)) { + console.warn(`Invalid Host header attempt: ${hostHeader}`); + return res.status(400).json({ message: 'Invalid Host header' }); + } + next(); + }; + + // Apply middleware + app.use(hostValidationMiddleware); + + // Swagger password protection + const swaggerAuthMiddleware = new SwaggerAuthMiddleware(configService); + app.use(swaggerAuthMiddleware.use.bind(swaggerAuthMiddleware)); + + app.use('/cdn', express.static(join(__dirname, '..', 'public'))); + + // Versioning + app.enableVersioning({ + defaultVersion: VERSION_NEUTRAL, + type: VersioningType.URI, + }); + + // Swagger + const config = new DocumentBuilder() + .setTitle('CHATBOT_V2') + .setDescription('The new version of Ai-based chatbot swagger.') + .setVersion('1.0') + .addServer(`http://${process.env.BASEURL}:${process.env.PORT}`) + .addServer(`${process.env.DEV_SERVER_URL}`) + .addServer(`${process.env.PROD_SERVER_URL}`) + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('docs', app, document); + + // CORS Configuration + app.enableCors({ + origin: (origin, callback) => { + const allowedOrigins = configService.get('CORS_ORIGINS'); + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: true, + exposedHeaders: [], // Explicitly expose no headers + }); + + // Global pipes + app.useGlobalPipes( + new ValidationPipe({ + whitelist: false, + forbidNonWhitelisted: false, + transform: true, + }), + ); + + // Apply global exception filter + const httpAdapter = app.get(HttpAdapterHost); + app.useGlobalFilters(new AllExceptionsFilter(httpAdapter, app)); + + // Security headers + app.getHttpAdapter().getInstance().disable('x-powered-by'); + app.getHttpAdapter().getInstance().disable('keep-alive'); + app.getHttpAdapter().getInstance().set('Connection', 'close'); + app.use((req, res, next) => { + res.removeHeader('Connection'); + res.removeHeader('Keep-Alive'); + next(); + }); + // After app initialization + app.use(new SecurityHeadersMiddleware().use); + + // Serve static files + app.useStaticAssets(join(__dirname, '..', 'public')); + + app.useStaticAssets(join(__dirname, '..', 'uploads'), { + prefix: '/uploads', + }); + + const port = configService.get('PORT', 8585); + await app.listen(port); + // console.log(`Application is running on: http://localhost:${port}`); +} + +bootstrap(); diff --git a/src/policies/policies.controller.ts b/src/policies/policies.controller.ts new file mode 100644 index 0000000..a340909 --- /dev/null +++ b/src/policies/policies.controller.ts @@ -0,0 +1,68 @@ +import { Controller, Post, Get, HttpStatus } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentIdentity } from 'src/common/decorators/Identity.decorator'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { PoliciesService } from './policies.service'; + +@Controller('policies') +@ApiTags('Policies') +export class PoliciesController { + constructor(private readonly policiesService: PoliciesService) {} + + @ApiOperation({ + summary: 'Get user policies with installments', + }) + @ApiBearerAuth() + @Get() + async getUserPolicies(@CurrentIdentity() user: any) { + try { + const data = await this.policiesService.getUserPoliciesWithInstallments( + user._id, + ); + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } catch (error) { + return new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'Failed to fetch policies', + null, + ); + } + } + + @ApiOperation({ + summary: 'Refresh user policies from Saman API', + }) + @ApiBearerAuth() + @Post('refresh') + async refreshPolicies(@CurrentIdentity() user: any) { + try { + if (!user.nationalCode) { + return new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + 'National code not found', + null, + ); + } + + const result = + await this.policiesService.fetchAndStorePoliciesWithInstallments( + user._id, + user.nationalCode, + ); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + message: 'Policies refreshed successfully', + policiesCount: result.policiesCount, + installmentsCount: result.installmentsCount, + }); + } catch (error) { + return new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'Failed to refresh policies', + null, + ); + } + } +} + +// Made with Bob diff --git a/src/policies/policies.module.ts b/src/policies/policies.module.ts new file mode 100644 index 0000000..5d63e42 --- /dev/null +++ b/src/policies/policies.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { PoliciesController } from './policies.controller'; +import { PoliciesService } from './policies.service'; +import { PolicyModel, PolicySchema } from 'src/database/model/policy.model'; +import { + InstallmentModel, + InstallmentSchema, +} from 'src/database/model/installment.model'; +import { + UserInsuranceSnapshotModel, + UserInsuranceSnapshotSchema, +} from 'src/database/model/user-insurance-snapshot.model'; +import { SamanInsuranceModule } from 'src/externals/saman-insurance/saman-insurance.module'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: PolicyModel.name, schema: PolicySchema }, + { name: InstallmentModel.name, schema: InstallmentSchema }, + { + name: UserInsuranceSnapshotModel.name, + schema: UserInsuranceSnapshotSchema, + }, + ]), + SamanInsuranceModule, + ], + controllers: [PoliciesController], + providers: [PoliciesService], + exports: [PoliciesService], +}) +export class PoliciesModule {} + +// Made with Bob diff --git a/src/policies/policies.service.ts b/src/policies/policies.service.ts new file mode 100644 index 0000000..74e84d3 --- /dev/null +++ b/src/policies/policies.service.ts @@ -0,0 +1,593 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { PolicyModel } from 'src/database/model/policy.model'; +import { InstallmentModel } from 'src/database/model/installment.model'; +import { UserInsuranceSnapshotModel } from 'src/database/model/user-insurance-snapshot.model'; +import { SamanInsuranceService } from 'src/externals/saman-insurance/saman-insurance.service'; + +const POLICY_TYPE_TO_AI_KEY: Record = { + fire: 'fireInsurancePolicies', + car: 'carInsurancePolicies', + health: 'healthInsurancePolicies', + cargo: 'cargoInsurancePolicies', + equipment: 'equipmentInsurancePolicies', + life: 'lifeInsurancePolicies', + travel: 'travelInsurancePolicies', + liability: 'liabilityInsurancePolicies', + personalAccident: 'personalAccidentInsurancePolicies', +}; + +const AI_POLICY_TYPE_KEYS = Object.values(POLICY_TYPE_TO_AI_KEY); +const DEFAULT_SNAPSHOT_MAX_AGE_HOURS = 24; + +@Injectable() +export class PoliciesService { + private readonly logger = new Logger(PoliciesService.name); + + constructor( + @InjectModel(PolicyModel.name) + private readonly policyModel: Model, + @InjectModel(InstallmentModel.name) + private readonly installmentModel: Model, + @InjectModel(UserInsuranceSnapshotModel.name) + private readonly snapshotModel: Model, + private readonly samanInsuranceService: SamanInsuranceService, + ) {} + + private toObjectId(userId: string | Types.ObjectId): Types.ObjectId { + return typeof userId === 'string' ? new Types.ObjectId(userId) : userId; + } + + private createSyncBatchId(userId: string | Types.ObjectId): string { + return `${userId.toString()}-${Date.now()}`; + } + + private createEmptyInsuranceData(): Record { + return AI_POLICY_TYPE_KEYS.reduce( + (acc, key) => { + acc[key] = []; + return acc; + }, + { errors: {} } as Record, + ); + } + + private createEmptyInstallmentsData(): Record { + return { + policyHolderInstallments: [], + errors: {}, + }; + } + + private getSnapshotMaxAgeMs(): number { + const hours = parseInt( + process.env.INSURANCE_SNAPSHOT_MAX_AGE_HOURS || + String(DEFAULT_SNAPSHOT_MAX_AGE_HOURS), + 10, + ); + return (Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_SNAPSHOT_MAX_AGE_HOURS) * + 60 * + 60 * + 1000; + } + + private isSnapshotStale(syncedAt?: Date): boolean { + if (!syncedAt) { + return true; + } + return Date.now() - new Date(syncedAt).getTime() > this.getSnapshotMaxAgeMs(); + } + + private snapshotToPayload(snapshot: UserInsuranceSnapshotModel): { + user_insurance_data: Record; + user_installments_data: Record; + } { + return { + user_insurance_data: snapshot.user_insurance_data, + user_installments_data: snapshot.user_installments_data, + }; + } + + private canRefreshFromApi(nationalCode?: string): boolean { + return ( + process.env.SSO_ENABLED === 'true' && + Boolean(nationalCode?.trim()) + ); + } + + private async tryRefreshSnapshot( + userId: string | Types.ObjectId, + nationalCode: string, + ): Promise { + try { + await this.fetchAndStorePoliciesWithInstallments(userId, nationalCode); + return true; + } catch (error) { + this.logger.warn( + `Snapshot refresh failed for user ${userId}, using existing data if available`, + error, + ); + return false; + } + } + + /** + * Fetch from Saman API, upsert policies/installments, mark stale records, + * and save an AI-ready snapshot for the user. + */ + async fetchAndStorePoliciesWithInstallments( + userId: string | Types.ObjectId, + nationalCode: string, + ): Promise<{ policiesCount: number; installmentsCount: number }> { + const userObjectId = this.toObjectId(userId); + const syncBatchId = this.createSyncBatchId(userId); + const syncedAt = new Date(); + + try { + this.logger.log( + `Fetching and storing policies for user ${userId}, national code: ${nationalCode}`, + ); + + const { policies, installments } = + await this.samanInsuranceService.fetchPoliciesWithInstallments( + nationalCode, + ); + + let policiesCount = 0; + for (const policy of policies) { + const { + policyType, + policyId, + policyNumber, + insuranceLineName, + insuranceLineCode, + policyHolderName, + insuredName, + policyIssueDate, + policyBeginDate, + policyEndDate, + policyStatus, + policyStatusCode, + uniquePolicyCode, + referrerName, + issuingUnit, + policyHolderCode, + agentCode, + address, + licensePlate, + vehicleType, + ...rest + } = policy; + + await this.policyModel.findOneAndUpdate( + { userId: userObjectId, policyId }, + { + $set: { + userId: userObjectId, + policyId, + policyNumber, + insuranceLineName, + insuranceLineCode, + policyHolderName, + insuredName, + policyIssueDate, + policyBeginDate, + policyEndDate, + policyStatus, + policyStatusCode, + uniquePolicyCode, + referrerName: referrerName || '', + issuingUnit: issuingUnit || '', + policyHolderCode: policyHolderCode || '', + agentCode: agentCode || 0, + policyType, + address: address || '', + licensePlate: licensePlate || '', + vehicleType: vehicleType || '', + additionalData: Object.keys(rest).length > 0 ? rest : undefined, + isActive: true, + syncBatchId, + lastSyncedAt: syncedAt, + lastUpdated: syncedAt, + }, + }, + { upsert: true, new: true }, + ); + policiesCount++; + } + + let installmentsCount = 0; + for (const installment of installments) { + await this.installmentModel.findOneAndUpdate( + { installmentId: installment.installmentId }, + { + $set: { + policyId: installment.policyId, + userId: userObjectId, + installmentId: installment.installmentId, + paymentMethod: installment.paymentMethod || '', + installmentYear: installment.installmentYear || '', + installmentNumber: installment.installmentNumber || 0, + installmentPaymentAmount: installment.installmentPaymentAmount || 0, + remainingAmount: installment.remainingAmount || 0, + installmentDate: installment.installmentDate || '', + installmentPaymentDate: installment.installmentPaymentDate || '', + installmentStatus: installment.installmentStatus || '', + totalInstallments: installment.totalInstallments || 0, + paidInstallments: installment.paidInstallments || 0, + upcomingInstallmentCount: installment.upcomingInstallmentCount || 0, + overdueInstallmentCount: installment.overdueInstallmentCount || 0, + bankId: installment.bankId || '', + totalPremiumCollected: installment.totalPremiumCollected || 0, + isActive: true, + syncBatchId, + lastSyncedAt: syncedAt, + lastUpdated: syncedAt, + }, + }, + { upsert: true, new: true }, + ); + installmentsCount++; + } + + await this.policyModel.updateMany( + { userId: userObjectId, syncBatchId: { $ne: syncBatchId } }, + { $set: { isActive: false } }, + ); + + await this.installmentModel.updateMany( + { userId: userObjectId, syncBatchId: { $ne: syncBatchId } }, + { $set: { isActive: false } }, + ); + + const activePolicies = await this.policyModel + .find({ userId: userObjectId, isActive: true, policyStatusCode: 1 }) + .lean() + .exec(); + + const activePolicyIds = activePolicies.map((p) => p.policyId); + const activeInstallments = activePolicyIds.length + ? await this.installmentModel + .find({ + userId: userObjectId, + isActive: true, + policyId: { $in: activePolicyIds }, + }) + .lean() + .exec() + : []; + + const aiPayload = this.buildAiPayload(activePolicies, activeInstallments); + + await this.snapshotModel.findOneAndUpdate( + { userId: userObjectId }, + { + $set: { + userId: userObjectId, + syncBatchId, + syncedAt, + user_insurance_data: aiPayload.user_insurance_data, + user_installments_data: aiPayload.user_installments_data, + }, + }, + { upsert: true, new: true }, + ); + + this.logger.log( + `Synced ${policiesCount} policies and ${installmentsCount} installments for user ${userId}`, + ); + + return { policiesCount, installmentsCount }; + } catch (error) { + this.logger.error( + `Error fetching and storing policies for user ${userId}:`, + error, + ); + throw error; + } + } + + private buildAiPayload( + activePolicies: PolicyModel[], + installments: InstallmentModel[], + ): { + user_insurance_data: Record; + user_installments_data: Record; + } { + const userInsuranceData = this.createEmptyInsuranceData(); + + for (const policy of activePolicies) { + const aiKey = POLICY_TYPE_TO_AI_KEY[policy.policyType]; + if (!aiKey) { + continue; + } + + const basePolicy = { + policyId: policy.policyId, + policyNumber: policy.policyNumber, + insuranceLineName: policy.insuranceLineName, + policyHolderName: policy.policyHolderName, + policyIssueDate: policy.policyIssueDate, + policyBeginDate: policy.policyBeginDate, + policyEndDate: policy.policyEndDate, + policyStatus: policy.policyStatus, + }; + + let mappedPolicy: Record; + switch (policy.policyType) { + case 'fire': + mappedPolicy = { ...basePolicy, address: policy.address || '' }; + break; + case 'car': + mappedPolicy = { + ...basePolicy, + licensePlate: policy.licensePlate || '', + vehicleType: policy.vehicleType || '', + }; + break; + default: + mappedPolicy = { + ...basePolicy, + ...(policy.additionalData || {}), + }; + break; + } + + (userInsuranceData[aiKey] as Record[]).push( + mappedPolicy, + ); + } + + const policyHolderInstallments = installments.map((inst) => ({ + policyId: inst.policyId, + installmentId: inst.installmentId, + installmentDate: inst.installmentDate || '', + installmentPaymentAmount: inst.installmentPaymentAmount || 0, + installmentStatus: inst.installmentStatus || '', + paymentMethod: inst.paymentMethod || '', + installmentYear: inst.installmentYear || '', + totalInstallments: inst.totalInstallments || 0, + paidInstallments: inst.paidInstallments || 0, + upcomingInstallmentCount: inst.upcomingInstallmentCount || 0, + overdueInstallmentCount: inst.overdueInstallmentCount || 0, + totalPremiumCollected: inst.totalPremiumCollected || 0, + installmentNumber: inst.installmentNumber || 0, + remainingAmount: inst.remainingAmount || 0, + installmentPaymentDate: inst.installmentPaymentDate || '', + })); + + return { + user_insurance_data: userInsuranceData, + user_installments_data: { + policyHolderInstallments, + errors: {}, + }, + }; + } + + /** + * Read the latest AI-ready snapshot for a user. + * Refreshes from Saman API when snapshot is missing or older than 24h. + * On refresh failure, returns the last known snapshot if available. + */ + async getUserDataForAi( + userId: string | Types.ObjectId, + nationalCode?: string, + ): Promise<{ + user_insurance_data: Record; + user_installments_data: Record; + }> { + const userObjectId = this.toObjectId(userId); + + try { + const snapshot = await this.snapshotModel + .findOne({ userId: userObjectId }) + .lean() + .exec(); + + const needsRefresh = + !snapshot || this.isSnapshotStale(snapshot.syncedAt); + + if (needsRefresh && this.canRefreshFromApi(nationalCode)) { + const refreshed = await this.tryRefreshSnapshot( + userId, + nationalCode.trim(), + ); + + if (refreshed) { + const freshSnapshot = await this.snapshotModel + .findOne({ userId: userObjectId }) + .lean() + .exec(); + + if (freshSnapshot) { + return this.snapshotToPayload(freshSnapshot); + } + } + + if (snapshot) { + return this.snapshotToPayload(snapshot); + } + } + + if (snapshot) { + return this.snapshotToPayload(snapshot); + } + + return this.buildAiPayloadFromDb(userObjectId); + } catch (error) { + this.logger.error( + `Error reading AI payload for user ${userId}:`, + error, + ); + return { + user_insurance_data: { + ...this.createEmptyInsuranceData(), + errors: { fetchError: 'Failed to load user insurance data' }, + }, + user_installments_data: { + ...this.createEmptyInstallmentsData(), + errors: { fetchError: 'Failed to load user installments data' }, + }, + }; + } + } + + private async buildAiPayloadFromDb(userObjectId: Types.ObjectId): Promise<{ + user_insurance_data: Record; + user_installments_data: Record; + }> { + const activePolicies = await this.policyModel + .find({ userId: userObjectId, isActive: true, policyStatusCode: 1 }) + .lean() + .exec(); + + if (!activePolicies.length) { + return { + user_insurance_data: this.createEmptyInsuranceData(), + user_installments_data: this.createEmptyInstallmentsData(), + }; + } + + const activePolicyIds = activePolicies.map((p) => p.policyId); + const installments = await this.installmentModel + .find({ + userId: userObjectId, + isActive: true, + policyId: { $in: activePolicyIds }, + }) + .lean() + .exec(); + + return this.buildAiPayload(activePolicies, installments); + } + + async getUserPolicies(userId: string | Types.ObjectId): Promise { + const userObjectId = this.toObjectId(userId); + + return await this.policyModel + .find({ userId: userObjectId, isActive: true }) + .sort({ lastUpdated: -1 }) + .lean() + .exec(); + } + + async getPolicyInstallments(policyId: number): Promise { + return await this.installmentModel + .find({ policyId, isActive: true }) + .sort({ installmentDate: -1 }) + .lean() + .exec(); + } + + async getUserPoliciesWithInstallments( + userId: string | Types.ObjectId, + ): Promise { + try { + const userObjectId = this.toObjectId(userId); + + const policies = await this.policyModel + .find({ userId: userObjectId, isActive: true }) + .lean() + .exec(); + + if (!policies.length) { + return { + total_policies: 0, + active_policies: 0, + policies: [], + }; + } + + const policyIds = policies.map((p) => p.policyId); + const allInstallments = await this.installmentModel + .find({ userId: userObjectId, isActive: true, policyId: { $in: policyIds } }) + .lean() + .exec(); + + const installmentsByPolicy = allInstallments.reduce((acc, inst) => { + if (!acc[inst.policyId]) { + acc[inst.policyId] = []; + } + acc[inst.policyId].push(inst); + return acc; + }, {}); + + const policiesWithInstallments = policies.map((policy) => { + const policyInstallments = installmentsByPolicy[policy.policyId] || []; + + policyInstallments.sort((a, b) => { + if (a.installmentYear !== b.installmentYear) { + return b.installmentYear.localeCompare(a.installmentYear); + } + return (b.installmentNumber || 0) - (a.installmentNumber || 0); + }); + + const firstInstallment = policyInstallments[0]; + + return { + policy_info: { + policyId: policy.policyId, + policyNumber: policy.policyNumber, + insuranceLineName: policy.insuranceLineName, + insuranceLineCode: policy.insuranceLineCode, + policyHolderName: policy.policyHolderName, + insuredName: policy.insuredName, + policyIssueDate: policy.policyIssueDate, + policyBeginDate: policy.policyBeginDate, + policyEndDate: policy.policyEndDate, + policyStatus: policy.policyStatus, + policyStatusCode: policy.policyStatusCode, + uniquePolicyCode: policy.uniquePolicyCode, + referrerName: policy.referrerName, + issuingUnit: policy.issuingUnit, + policyType: policy.policyType, + }, + installments: { + total_installments: firstInstallment?.totalInstallments || 0, + paid_installments: firstInstallment?.paidInstallments || 0, + upcoming_installments: firstInstallment?.upcomingInstallmentCount || 0, + overdue_installments: firstInstallment?.overdueInstallmentCount || 0, + total_premium_collected: firstInstallment?.totalPremiumCollected || 0, + installment_details: policyInstallments.map((inst) => ({ + installmentId: inst.installmentId, + installmentYear: inst.installmentYear, + installmentNumber: inst.installmentNumber, + installmentPaymentAmount: inst.installmentPaymentAmount, + remainingAmount: inst.remainingAmount, + installmentDate: inst.installmentDate, + installmentPaymentDate: inst.installmentPaymentDate, + installmentStatus: inst.installmentStatus, + paymentMethod: inst.paymentMethod, + })), + }, + }; + }); + + const activePolicies = policies.filter((p) => p.policyStatusCode === 1); + + return { + total_policies: policies.length, + active_policies: activePolicies.length, + policies: policiesWithInstallments, + }; + } catch (error) { + this.logger.error( + `Error getting policies with installments for user ${userId}:`, + error, + ); + throw error; + } + } + + async deleteUserPolicies(userId: string | Types.ObjectId): Promise { + const userObjectId = this.toObjectId(userId); + + await this.policyModel.deleteMany({ userId: userObjectId }); + await this.installmentModel.deleteMany({ userId: userObjectId }); + await this.snapshotModel.deleteOne({ userId: userObjectId }); + + this.logger.log(`Deleted all policies and installments for user ${userId}`); + } +} diff --git a/src/reports/dto/create-report.dto.ts b/src/reports/dto/create-report.dto.ts new file mode 100644 index 0000000..71a2e90 --- /dev/null +++ b/src/reports/dto/create-report.dto.ts @@ -0,0 +1 @@ +export class CreateReportDto {} diff --git a/src/reports/dto/update-report.dto.ts b/src/reports/dto/update-report.dto.ts new file mode 100644 index 0000000..95b48d8 --- /dev/null +++ b/src/reports/dto/update-report.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateReportDto } from './create-report.dto'; + +export class UpdateReportDto extends PartialType(CreateReportDto) {} diff --git a/src/reports/reports.controller.ts b/src/reports/reports.controller.ts new file mode 100644 index 0000000..1c45230 --- /dev/null +++ b/src/reports/reports.controller.ts @@ -0,0 +1,366 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + Query, + Res, +} from '@nestjs/common'; +import { Response } from 'express'; +import { CreateReportDto } from './dto/create-report.dto'; +import { UpdateReportDto } from './dto/update-report.dto'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { ReportsService } from './reports.service'; +import { SkipAdminRateLimit } from 'src/common/decorators/skip-admin-rate-limit.decorator'; // Import the new decorator + +@ApiBearerAuth() +@Permissions(Permission.ReportsView) +@UseGuards(AdminGuard) +@SkipAdminRateLimit() // Apply the decorator to the entire controller +@ApiTags('reports module') +@Controller('reports') +export class ReportsController { + constructor(private readonly reportsService: ReportsService) {} + + @Get('/bot/total-asked') + @ApiOperation({ + summary: 'count of total questions users asked', + }) + totalAsked() { + return this.reportsService.totalAsked(); + } + + @Get('/bot/bot-response-total-report') + @ApiOperation({ + summary: 'bot response report to users asked questions', + }) + @ApiQuery({ + name: 'date', + required: true, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', + }) + totalBotAnswering(@Query('date') dateRange: string) { + return this.reportsService.totalBotAnswering(dateRange); + } + + @Get('/bot/connect-to-expert-rate') + @ApiOperation({ + summary: 'user connection to expert rate.', + }) + @ApiQuery({ + name: 'date', + required: true, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', + }) + connectToExpertRate(@Query('date') dateRange: string) { + return this.reportsService.connectToExpertRate(dateRange); + } + + @Get('/user/top-ten-questions') + @ApiOperation({ + summary: 'user report | last 10 questions', + }) + topTenQuestions() { + return this.reportsService.topTenQuestions(); + } + + @Get('/user/top-ten-users') + @ApiOperation({ + summary: 'user report | top 10 users', + }) + topTenUsers() { + return this.reportsService.topTenUsers(); + } + + @Get('/user/bot-answer-react') + @ApiOperation({ + summary: + 'user report | report of bot answers to user questions (liked , disliked , none action)', + }) + @ApiQuery({ + name: 'date', + required: true, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', // Example value for Swagger UI + }) + @ApiQuery({ + name: 'status', + required: false, + enum: ["all", "bot", "online"], + description: 'Filter sessions by expert status: all, bot-handled, or online-expert-handled', + example: 'all', + }) + totalBotAnswerRate(@Query('date') dateRange: string, @Query('status') status?: "all" | "bot" | "online") { + return this.reportsService.botAnswerReact(dateRange, status); + } + + @Get('/user/expert-answer-rate') + @ApiOperation({ + summary: + 'user report | report of user rates to the expert answers , stars from 1 to 5', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', // Example value for Swagger UI + }) + expertAnswerRate(@Query('date') dateRange?: string) { + let startDate: string | undefined; + let endDate: string | undefined; + + if (dateRange) { + [startDate, endDate] = dateRange.split('-'); + } + return this.reportsService.expertAnswerRate(startDate, endDate); + } + + @Get('/expert/total-expert-answering') + @ApiOperation({ + summary: 'user report | totalAnswering', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', // Example value for Swagger UI + }) + @ApiQuery({ + name: 'experts', + required: false, + type: String, + isArray: true, + description: + 'Filter by expert usernames. Accepts comma-separated values or repeated query params: experts=a,b or experts=a&experts=b', + example: 'expert1@chatbot.com,expert2@chatbot.com', + }) + totalAnswering( + @Query('date') dateRange?: string, + @Query('experts') expertsQuery?: string | string[], + ) { + const experts = Array.isArray(expertsQuery) + ? expertsQuery + : expertsQuery + ? expertsQuery.split(',') + : undefined; + const normalizedExperts = experts?.map((e) => e.trim()).filter(Boolean); + return this.reportsService.totalAnswering(dateRange, normalizedExperts); + } + + @Get('/bot/total-react') + @ApiOperation({ + summary: 'Bot responses report: total bot messages, rated messages (likes/dislikes), and reaction counts', + description: 'Returns statistics for bot responses in bot-only sessions (sessions not connected to expert)', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', + }) + totalBotAnswersReact(@Query('date') dateRange?: string) { + return this.reportsService.totalBotAnswersReact(dateRange); + } + + @Get('/bot/total-connect-to-expert-rate') + @ApiOperation({ + summary: 'total user connection to expert rate without date filter.', + }) + totalConnectToExpertRate() { + return this.reportsService.totalConnectToExpertRate(); + } + + @Get('/user/activeUsers') + @ApiOperation({ + summary: 'total active users.', + }) + activeUsers() { + return this.reportsService.activeUsers(); + } + + @Get('/user/total-sessions') + @ApiOperation({ + summary: 'total sessions count with optional date filter', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31"). If not provided, returns total count of all sessions.', + example: '1404/01/01-1404/01/31', + }) + totalSessions(@Query('date') dateRange?: string) { + return this.reportsService.totalSessions(dateRange); + } + + @Permissions(Permission.ReportsExpertTiming) + @Get('/expert/avg-expert-answering') + @ApiOperation({ + summary: 'user report | average time experts took to answer the questions', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', // Example value for Swagger UI + }) + @ApiQuery({ + name: 'experts', + required: false, + type: String, + isArray: true, + description: + 'Filter by expert usernames. Accepts comma-separated values or repeated query params: experts=a,b or experts=a&experts=b', + example: 'expert1@chatbot.com,expert2@chatbot.com', + }) + avgAnswering( + @Query('date') dateRange?: string, + @Query('experts') expertsQuery?: string | string[], + ) { + const experts = Array.isArray(expertsQuery) + ? expertsQuery + : expertsQuery + ? expertsQuery.split(',') + : undefined; + const normalizedExperts = experts?.map((e) => e.trim()).filter(Boolean); + return this.reportsService.avgAnswering(dateRange, normalizedExperts); + } + + @Permissions(Permission.ReportsExpertTiming) + @Get('/expert/avg-waiting-time') + @ApiOperation({ + summary: 'user report | average waiting time experts took to answer the questions', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31")', + example: '1404/01/01-1404/01/31', // Example value for Swagger UI + }) + @ApiQuery({ + name: 'experts', + required: false, + type: String, + isArray: true, + description: + 'Filter by expert usernames. Accepts comma-separated values or repeated query params: experts=a,b or experts=a&experts=b', + example: 'expert1@chatbot.com,expert2@chatbot.com', + }) + avgWaitingTime( + @Query('date') dateRange?: string, + @Query('experts') expertsQuery?: string | string[], + ) { + const experts = Array.isArray(expertsQuery) + ? expertsQuery + : expertsQuery + ? expertsQuery.split(',') + : undefined; + const normalizedExperts = experts?.map((e) => e.trim()).filter(Boolean); + return this.reportsService.avgWaitingTime(dateRange, normalizedExperts); + } + + @Get('/expert/expertResponseRate') + @ApiOperation({ + summary: 'Expert response rate, average response time, and average user waiting time.', + }) + expertResponseRate() { + return this.reportsService.expertMetrics(); + } + + @Get('/expert/export-excel') + @ApiOperation({ + summary: 'Export expert reports to Excel with comprehensive data and charts', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by date range in Persian format (e.g., "1404/01/01-1404/01/31"). Applies to answering and waiting time reports only.', + example: '1404/01/01-1404/01/31', + }) + @ApiQuery({ + name: 'experts', + required: false, + type: String, + isArray: true, + description: + 'Filter by expert usernames. Accepts comma-separated values or repeated query params: experts=a,b or experts=a&experts=b', + example: 'expert1@chatbot.com,expert2@chatbot.com', + }) + @ApiQuery({ + name: 'rate', + required: false, + type: Number, + description: + 'Filter expert list by average rating (1-5). Returns experts with average rating equal to the specified value.', + example: 4, + }) + @ApiQuery({ + name: 'search', + required: false, + type: String, + description: 'Search expert list by mobile or other fields', + example: '09999985840', + }) + async exportExpertReports( + @Query('date') dateRange?: string, + @Query('experts') expertsQuery?: string | string[], + @Query('rate') rate?: string, + @Query('search') search?: string, + @Res() res?: Response, + ) { + const experts = Array.isArray(expertsQuery) + ? expertsQuery + : expertsQuery + ? expertsQuery.split(',') + : undefined; + const normalizedExperts = experts?.map((e) => e.trim()).filter(Boolean); + + const buffer = await this.reportsService.exportExpertReportsToExcel( + dateRange, + normalizedExperts, + rate, + search, + ); + + const filename = `expert-reports-${new Date().getTime()}.xlsx`; + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); + } +} diff --git a/src/reports/reports.module.ts b/src/reports/reports.module.ts new file mode 100644 index 0000000..a12a283 --- /dev/null +++ b/src/reports/reports.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { UserManagementModule } from 'src/user-management/user-management.module'; +import { ReportsController } from './reports.controller'; +import { ReportsService } from './reports.service'; + +@Module({ + imports: [DatabaseModule, UserManagementModule], + controllers: [ReportsController], + providers: [ReportsService], +}) +export class ReportsModule {} diff --git a/src/reports/reports.service.ts b/src/reports/reports.service.ts new file mode 100644 index 0000000..ec2dee2 --- /dev/null +++ b/src/reports/reports.service.ts @@ -0,0 +1,1631 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { AdminModel } from 'src/database/model/admin.model'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { UserModel } from 'src/database/model/user.model'; +import { ReassignedLogsModel } from 'src/database/model/reassigned-logs.model'; +import { UserManagementService } from 'src/user-management/user-management.service'; +import * as ExcelJS from 'exceljs'; + +@Injectable() +export class ReportsService { + constructor( + @InjectModel(SessionModel.name) + private readonly sessionModel: Model, + @InjectModel(UserModel.name) private readonly userModel: Model, + @InjectModel(AdminModel.name) + private readonly adminModel: Model, + @InjectModel(ReassignedLogsModel.name) + private readonly reassignedLogsModel: Model, + private readonly userManagementService: UserManagementService, + ) {} + async totalBotAnswering(dateRange: string) { + try { + // Split the date range + const [startDate, endDate] = dateRange.split('-'); + // Get date segments + const dateSegments = TimeHelper.divideDateRange(startDate, endDate); + + // Initialize data structure + const data = { + labels: dateSegments.map((segment) => ({ + start: segment.start, + end: segment.end, + })), + data: await Promise.all( + dateSegments.map(async (segment) => { + // Convert dates to ISO for MongoDB query + const startISO = TimeHelper.jalaliToISO(segment.start); + const endISO = TimeHelper.jalaliToISO(segment.end); + const startDate = new Date(startISO); + const endDate = new Date(endISO); + startDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + + // Build query for sessions created in date range + // Note: We count bot messages from ALL sessions, not just bot-only sessions, + // because sessions that connect to expert may still have bot messages that were rated + const query: any = { + createdISO: { + $gte: startDate, + $lte: endDate, + }, + }; + + // Query sessions with messages field, use lean for performance + const sessions = await this.sessionModel + .find(query) + .select('messages') + .lean() + .exec(); + + // Count reactions - count all bot messages from sessions created in date range + let positiveQuestions = 0; + let negativeQuestions = 0; + let totalBotMessages = 0; + + sessions.forEach((session) => { + session.messages?.forEach((message) => { + // Count all bot messages (regardless of message date) + if (message.sender === 'Bot') { + totalBotMessages++; + // Normalize react value: trim whitespace and convert to lowercase + const react = message.react ? String(message.react).trim().toLowerCase() : null; + if (react === 'like') { + positiveQuestions++; + } else if (react === 'dislike') { + negativeQuestions++; + } + } + }); + }); + + return { + start: segment.start, + end: segment.end, + positiveQuestions, + negativeQuestions, + totalBotMessages, // Add total bot messages to response + }; + }), + ), + }; + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } catch (err) { + console.error('Error in totalBotAnswering:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async connectToExpertRate(dateRange: string) { + try { + // Split the date range + const [startDate, endDate] = dateRange.split('-'); + + // Get date segments + const dateSegments = TimeHelper.divideDateRange(startDate, endDate); + + // Initialize data structure + const data = { + labels: dateSegments.map((segment) => ({ + start: segment.start, + end: segment.end, + })), + data: await Promise.all( + dateSegments.map(async (segment) => { + // Convert dates to ISO for MongoDB query + const startISO = TimeHelper.jalaliToISO(segment.start); + const endISO = TimeHelper.jalaliToISO(segment.end); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + // Query sessions within date range with additional fields for validation + const sessions = await this.sessionModel + .find({ + createdISO: { + $gte: startDateObj, + $lte: endDateObj, + }, + }) + .select('connectedToExpert expert onlineStartDate') + .lean() + .exec(); + + // Calculate expert connection rates + // Use simple, reliable counting logic + const totalSessions = sessions.length; + + // Count sessions where connectedToExpert is explicitly true + // Using strict equality to ensure we only count actual true values + const connectedToExpert = sessions.filter( + (session) => session.connectedToExpert === true, + ).length; + + // Calculate notConnectedToExpert as the difference + // This is more reliable than filtering, as it ensures total = connected + notConnected + // This handles all edge cases: false, null, undefined, or missing field + const notConnectedToExpert = totalSessions - connectedToExpert; + + // Data integrity validation: Check for suspicious patterns + // Sessions with connectedToExpert: true should have expert field and onlineStartDate + const suspiciousSessions = sessions.filter( + (session) => + session.connectedToExpert === true && + (!session.expert || !session.onlineStartDate) + ).length; + + // Debug logging for production troubleshooting + // Only log if there are unexpected value types (null, undefined, or missing fields) + if (sessions.length > 0) { + const valueTypes = { + true: sessions.filter(s => s.connectedToExpert === true).length, + false: sessions.filter(s => s.connectedToExpert === false).length, + null: sessions.filter(s => s.connectedToExpert === null).length, + undefined: sessions.filter(s => s.connectedToExpert === undefined).length, + missing: sessions.filter(s => !('connectedToExpert' in s)).length, + }; + + // Only log if there are unexpected value types (for debugging) + if (valueTypes.null > 0 || valueTypes.undefined > 0 || valueTypes.missing > 0) { + console.warn(`[connectToExpertRate] Unexpected value types for segment ${segment.start}-${segment.end}:`, valueTypes); + } + + // Log suspicious sessions (connectedToExpert: true but missing required fields) + if (suspiciousSessions > 0) { + console.warn(`[connectToExpertRate] ⚠️ Data integrity issue detected for segment ${segment.start}-${segment.end}: ${suspiciousSessions} sessions marked as connectedToExpert: true but missing expert field or onlineStartDate`); + } + } + + // Validate and log if there's any unexpected data (shouldn't happen with this logic) + if (totalSessions !== connectedToExpert + notConnectedToExpert) { + console.error(`[connectToExpertRate] Critical data inconsistency detected for segment ${segment.start}-${segment.end}: total=${totalSessions}, connected=${connectedToExpert}, notConnected=${notConnectedToExpert}`); + } + + // Log warning if connection rate seems unusually high (potential data issue) + const connectionRate = totalSessions > 0 ? (connectedToExpert / totalSessions) * 100 : 0; + if (connectionRate > 60 && totalSessions > 10) { + console.warn(`[connectToExpertRate] ⚠️ Unusually high connection rate (${connectionRate.toFixed(1)}%) for segment ${segment.start}-${segment.end}. This may indicate a data integrity issue.`); + } + + const result = { + start: segment.start, + end: segment.end, + totalSessions, + connectedToExpert: connectedToExpert, + notConnectedToExpert: notConnectedToExpert, + connectionRate: + totalSessions > 0 + ? Math.round((connectedToExpert / totalSessions) * 100) + : 0, + // Add data integrity flags + dataIntegrityWarning: suspiciousSessions > 0 || connectionRate > 60, + suspiciousSessionsCount: suspiciousSessions, + }; + + return result; + }), + ), + }; + + // Calculate summary totals across all segments for verification + const summary = { + totalSessions: data.data.reduce((sum, segment) => sum + segment.totalSessions, 0), + totalConnectedToExpert: data.data.reduce((sum, segment) => sum + segment.connectedToExpert, 0), + totalNotConnectedToExpert: data.data.reduce((sum, segment) => sum + segment.notConnectedToExpert, 0), + overallConnectionRate: 0, + totalSuspiciousSessions: data.data.reduce((sum, segment) => sum + (segment.suspiciousSessionsCount || 0), 0), + }; + summary.overallConnectionRate = summary.totalSessions > 0 + ? Math.round((summary.totalConnectedToExpert / summary.totalSessions) * 100) + : 0; + + // Validate summary totals match + if (summary.totalSessions !== summary.totalConnectedToExpert + summary.totalNotConnectedToExpert) { + console.error('[connectToExpertRate] Summary validation failed:', summary); + } + + // Log overall data integrity warning if connection rate is unusually high + if (summary.overallConnectionRate > 60 && summary.totalSessions > 50) { + console.warn(`[connectToExpertRate] ⚠️ Overall connection rate is ${summary.overallConnectionRate}% which seems unusually high. This may indicate a data integrity issue.`); + console.warn(`[connectToExpertRate] Summary: ${summary.totalSessions} total sessions, ${summary.totalConnectedToExpert} connected, ${summary.totalNotConnectedToExpert} not connected`); + console.warn(`[connectToExpertRate] Suspicious sessions (connectedToExpert: true but missing expert/onlineStartDate): ${summary.totalSuspiciousSessions}`); + } + + // Add summary to response for easy verification + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + ...data, + summary, + }); + } catch (err) { + console.error('Error in connectToExpertRate:', err); + // If error is already a BaseResponseDTO, re-throw it + if (err instanceof BaseResponseDTO) { + throw err; + } + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async totalAsked() { + try { + const sessions = await this.sessionModel + .find({}) + .select('messages') + .lean() + .exec(); + + const totalAsked = sessions.reduce((total, session) => { + return ( + total + (session.messages?.filter((msg) => msg.sender === 'User').length || 0) + ); + }, 0); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + count: totalAsked, + }); + } catch (err) { + console.error('Error in totalAsked:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async avgAnswering(dateRange?: string, experts?: string[]) { + try { + // Build expert list (either provided or all existing experts) + // Only include active experts (isActive: true) to avoid ruining the results + let expertsList: { username: string; name?: string; family?: string }[]; + if (experts && experts.length > 0) { + // First, check all requested experts (both active and inactive) to validate + const allRequestedExperts = await this.adminModel + .find({ role: 'expert', username: { $in: experts } }) + .lean(); + + // Check for inactive experts + const inactiveExperts = allRequestedExperts + .filter((a) => !a.isActive) + .map((a) => a.username); + + // If any inactive experts are found, return an error + if (inactiveExperts.length > 0) { + throw new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + `Cannot calculate average for inactive experts: ${inactiveExperts.join(', ')}`, + { inactiveExperts }, + ); + } + + // Check for experts that don't exist in database + const foundUsernames = new Set(allRequestedExperts.map((a) => a.username)); + const notFound = experts.filter((u) => !foundUsernames.has(u)); + + if (notFound.length > 0) { + throw new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + `Experts not found: ${notFound.join(', ')}`, + { notFound }, + ); + } + + // All experts are active and exist, proceed with active experts only + const admins = await this.adminModel + .find({ role: 'expert', username: { $in: experts }, isActive: true }) + .lean(); + expertsList = admins.map((a) => ({ username: a.username, name: a.name, family: a.family })); + } else { + const allExperts = await this.adminModel.find({ role: 'expert', isActive: true }).lean(); + expertsList = allExperts.map((e) => ({ username: e.username, name: e.name, family: e.family })); + } + + const result = await Promise.all( + expertsList.map(async (expert) => { + // Build query + const query: any = { + expert: expert.username, + connectedToExpert: true, + onlineChatClosed: true, + onlineStartDate: { $exists: true, $ne: null }, + onlineEndDate: { $exists: true, $ne: null }, + }; + + // Add date filter if provided + if (dateRange) { + const [startDate, endDate] = dateRange.split('-'); + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + query.createdISO = { + $gte: new Date(startISO), + $lte: new Date(endISO), + }; + } + + // Find all sessions for this expert + const sessions = await this.sessionModel.find(query); + + // Calculate average response time + let totalDuration = 0; + sessions.forEach((session) => { + const duration = Math.abs( + session.onlineEndDate.getTime() - + session.onlineStartDate.getTime(), + ); + totalDuration += duration; + }); + + const averageResponseTime = + sessions.length > 0 + ? Math.round((totalDuration / sessions.length / 60000) * 100) / + 100 // Convert to minutes and round to 2 decimals + : 0; + + return { + averageResponseTime, + firstName: expert.name || 'Unknown', + lastName: expert.family || 'Expert', + averageResponseTimeUnit: 'minute', + totalSessions: sessions.length, + }; + }), + ); + + // Sort by average response time + result.sort((a, b) => a.averageResponseTime - b.averageResponseTime); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', result); + } catch (err) { + console.error('Error in avgAnswering:', err); + // If error is already a BaseResponseDTO, re-throw it + if (err instanceof BaseResponseDTO) { + throw err; + } + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async avgWaitingTime(dateRange?: string, experts?: string[]) { + try { + const query: any = {}; + + // Date filter (optional) + if (dateRange) { + const [startDate, endDate] = dateRange.split('-'); + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + // Set bounds to cover full days + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + query.createdISO = { $gte: startDateObj, $lte: endDateObj }; + } + + // Experts filter (optional) + if (experts && experts.length > 0) { + query.expert = { $in: experts }; + } + + // If experts not specified, compute for all experts + // Only include active experts (isActive: true) to avoid ruining the results + let expertsList: { username: string; name?: string; family?: string }[]; + if (!experts || experts.length === 0) { + const allExperts = await this.adminModel.find({ role: 'expert', isActive: true }).lean(); + expertsList = allExperts.map((e) => ({ + username: e.username, + name: e.name, + family: e.family, + })); + } else { + // First, check all requested experts (both active and inactive) to validate + const allRequestedExperts = await this.adminModel + .find({ role: 'expert', username: { $in: experts } }) + .lean(); + + // Check for inactive experts + const inactiveExperts = allRequestedExperts + .filter((a) => !a.isActive) + .map((a) => a.username); + + // If any inactive experts are found, return an error + if (inactiveExperts.length > 0) { + throw new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + `Cannot calculate average waiting time for inactive experts: ${inactiveExperts.join(', ')}`, + { inactiveExperts }, + ); + } + + // Check for experts that don't exist in database + const foundUsernames = new Set(allRequestedExperts.map((a) => a.username)); + const notFound = experts.filter((u) => !foundUsernames.has(u)); + + if (notFound.length > 0) { + throw new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + `Experts not found: ${notFound.join(', ')}`, + { notFound }, + ); + } + + // All experts are active and exist, proceed with active experts only + const admins = await this.adminModel + .find({ role: 'expert', username: { $in: experts }, isActive: true }) + .lean(); + expertsList = admins.map((a) => ({ username: a.username, name: a.name, family: a.family })); + } + + const result = await Promise.all( + expertsList.map(async (exp) => { + const sessions = await this.sessionModel.find({ + ...query, + expert: exp.username, + }); + + let totalWaitingTime = 0; + let sessionsWithExpertMessage = 0; + + sessions.forEach((session) => { + if (!session.onlineStartDate) return; + const onlineStartDate = new Date(session.onlineStartDate); + + const firstExpertMessage = session.messages.find( + (message) => message.sender?.toLowerCase() === 'expert', + ); + + if (firstExpertMessage?.createdISO) { + const expertMessageDate = new Date(firstExpertMessage.createdISO); + const waitingTimeInSeconds = + (expertMessageDate.getTime() - onlineStartDate.getTime()) / 1000; + if (waitingTimeInSeconds >= 0) { + totalWaitingTime += waitingTimeInSeconds; + sessionsWithExpertMessage++; + } + } + }); + + const avgWaitingTime = + sessionsWithExpertMessage > 0 + ? totalWaitingTime / sessionsWithExpertMessage + : 0; + + return { + firstName: exp.name || 'Unknown', + lastName: exp.family || 'Expert', + avgWaitingTime: parseFloat(avgWaitingTime.toFixed(2)), + }; + }), + ); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', result); + } catch (err) { + console.error('Error in avgWaitingTime:', err); + // If error is already a BaseResponseDTO, re-throw it + if (err instanceof BaseResponseDTO) { + throw err; + } + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async totalAnswering(dateRange?: string, experts?: string[]) { + try { + const query: any = { + connectedToExpert: true, + }; + + // Date filter (optional) + if (dateRange) { + const [startDate, endDate] = dateRange.split('-'); + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + query.createdISO = { $gte: startDateObj, $lte: endDateObj }; + } + + // Build expert list (either provided or all existing experts) + // Include email since reassigned_logs uses email in the 'from' field + let expertsList: { username: string; email?: string; name?: string; family?: string }[]; + if (experts && experts.length > 0) { + const admins = await this.adminModel + .find({ role: 'expert', username: { $in: experts } }) + .lean(); + const found = new Set(admins.map((a) => a.username)); + const missing = experts + .filter((u) => !found.has(u)) + .map((u) => ({ username: u })); + expertsList = [ + ...admins.map((a) => ({ username: a.username, email: a.email, name: a.name, family: a.family })), + ...missing, + ]; + } else { + const allExperts = await this.adminModel + .find({ role: 'expert' }) + .lean(); + expertsList = allExperts.map((e) => ({ username: e.username, email: e.email, name: e.name, family: e.family })); + } + + // Get sessionIds in date range for filtering reassigned_logs (if dateRange is provided) + let sessionIdsInDateRange: string[] = []; + if (dateRange) { + const [startDate, endDate] = dateRange.split('-'); + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + const sessionsInRange = await this.sessionModel + .find({ + createdISO: { $gte: startDateObj, $lte: endDateObj }, + }) + .select('_id') + .lean() + .exec(); + sessionIdsInDateRange = sessionsInRange.map(s => s._id.toString()); + } + + const result = await Promise.all( + expertsList.map(async (exp) => { + // Use expert's email to find sessions (expert field stores email) + // This matches exactly how getProfile function does it + const expertEmail = exp.email; + + // Skip experts without email (they shouldn't exist, but handle gracefully) + if (!expertEmail) { + return { + expert: exp.username, + firstName: exp.name || 'Unknown', + lastName: exp.family || 'Expert', + answered: 0, + notAnswered: 0, + total: 0, + fastest: 0, + }; + } + + // Build session query - match EXACTLY the query from getProfile function + // Calculate total answered: sessions where expert actually connected (connectedToExpert === true) + // and the expert field matches this expert's email + const sessionQuery: any = { + expert: expertEmail, + connectedToExpert: true, + onlineStartDate: { $exists: true }, + }; + + // Apply date filter if provided + if (dateRange) { + sessionQuery.createdISO = query.createdISO; + } + + // This matches the exact logic from getProfile function + const answeredSessions = await this.sessionModel.find(sessionQuery); + + // Calculate total answered (sessions where expert connected) + // This matches exactly: totalAnswered = answeredSessions.length + const answered = answeredSessions.length; + + // Calculate fastest response time (only from sessions with both start and end dates) + let fastestSeconds = Infinity; + answeredSessions.forEach((session) => { + if (session.onlineStartDate && session.onlineEndDate) { + const durationSec = + (new Date(session.onlineEndDate).getTime() - new Date(session.onlineStartDate).getTime()) / 1000; + if (durationSec >= 0 && durationSec < fastestSeconds) { + fastestSeconds = durationSec; + } + } + }); + + // Calculate total not answered: count from reassigned_logs where this expert missed the chat + // (reassigned from them, meaning they were online but didn't answer) + // This matches exactly the query from getProfile function: { from: expertEmail } + const notAnsweredQuery: any = { + from: expertEmail, + }; + + // If dateRange is provided, filter by sessionIds in that range + if (dateRange && sessionIdsInDateRange.length > 0) { + notAnsweredQuery.sessionId = { + $in: sessionIdsInDateRange.map(id => new Types.ObjectId(id)), + }; + } else if (dateRange && sessionIdsInDateRange.length === 0) { + // No sessions in date range, so no reassignments either + notAnsweredQuery.sessionId = { $in: [] }; // Empty array means no matches + } + + const notAnswered = await this.reassignedLogsModel.countDocuments(notAnsweredQuery); + + const fastest = fastestSeconds === Infinity ? 0 : parseFloat(fastestSeconds.toFixed(2)); + + return { + expert: exp.username, + firstName: exp.name || 'Unknown', + lastName: exp.family || 'Expert', + answered, + notAnswered, + total: answered + notAnswered, + fastest, + }; + }), + ); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', result); + } catch (err) { + console.error('Error in totalAnswering:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async expertAnswerRate( + startDate?: string, + endDate?: string, + ) { + try { + const query: any = { + connectedToExpert: true, + onlineChatClosed: true, + }; + + if (startDate && endDate) { + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + query['createdISO'] = { + $gte: startDateObj, + $lte: endDateObj, + }; + } + // Find all sessions that are connected to expert and closed + // (including both rated and non-rated sessions) + // Use lean() for better performance since we only need expertRate field + const sessions = await this.sessionModel + .find(query) + .select('expertRate') + .lean() + .exec(); + + // Initialize counts for each rate + const rateCounts = { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }; + + // Filter valid rates and count distribution + const validRates: number[] = []; + let nonRatedCount = 0; + + sessions.forEach((session) => { + const rate = session.expertRate; + if (rate != null && Number.isInteger(rate) && rate >= 1 && rate <= 5) { + validRates.push(rate); + rateCounts[rate as keyof typeof rateCounts]++; + } else { + // Count sessions without rating (null, undefined, or invalid) + nonRatedCount++; + } + }); + + // Calculate average rating (only from rated sessions) + const totalRatedSessions = validRates.length; + const sumOfRates = validRates.reduce((sum, rate) => sum + rate, 0); + const averageRating = totalRatedSessions > 0 + ? Math.round((sumOfRates / totalRatedSessions) * 100) / 100 // Round to 2 decimal places + : 0; + + // Total sessions includes both rated and non-rated + const totalSessions = totalRatedSessions + nonRatedCount; + + // Calculate weighted total: 1*count1 + 2*count2 + 3*count3 + 4*count4 + 5*count5 + // All ratings (1-5) are considered "likes" but weighted by their star value + const totalWeightedRates = + 1 * rateCounts[1] + + 2 * rateCounts[2] + + 3 * rateCounts[3] + + 4 * rateCounts[4] + + 5 * rateCounts[5]; + + // Maximum possible weighted happiness: if all rated sessions were 5 stars + const maxPossibleWeightedRates = totalRatedSessions * 5; + + // Calculate percentage: actual weighted happiness / maximum possible weighted happiness + // This shows what percentage of maximum possible "happiness" was achieved + const ratingPercentage = maxPossibleWeightedRates > 0 + ? Math.round((totalWeightedRates / maxPossibleWeightedRates) * 10000) / 100 // Round to 2 decimal places + : 0; + + // Transform the counts into the required format + const distribution = Object.entries(rateCounts).map(([rate, count]) => ({ + rate: parseInt(rate), + count, + })); + + const data = { + averageRating, + ratingPercentage, // Percentage of weighted happiness achieved (actual weighted rates / max possible if all were 5 stars) (0-100) + totalSessions, // Total sessions (rated + non-rated) + totalRatedSessions, // Only rated sessions + nonRatedCount, // Non-rated sessions count + distribution, + }; + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } catch (err) { + console.error('Error in expertAnswerRate:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + totalBotAnswerRate(dateRange: string) { + const data = [ + { + persianLabel: 'سوال نامفهوم', + englishLabel: 'unclear', + value: 342, + }, + { + persianLabel: 'پاسخ با بازخورد مثبت', + englishLabel: 'answered', + value: 418, + }, + { + persianLabel: 'پاسخ بدون بازخورد', + englishLabel: 'noFeedback', + value: 1367, + }, + { + persianLabel: 'اتصال به کارشناس', + englishLabel: 'connectToExpert', + value: 218, + }, + ]; + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } + + async topTenQuestions() { + try { + const sessions = await this.sessionModel + .find() + .sort({ createdISO: -1 }) + .limit(10) + .exec(); + + const data = sessions.map((session, index) => ({ + rank: index + 1, + chatTitle: session.chatTitle, + createdISO: session.createdISO, + })); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } catch (error) { + console.error('Error in topTenQuestions:', error); + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'Error fetching top ten questions', + null, + ); + } + } + + async topTenUsers() { + try { + const topUsers = await this.sessionModel.aggregate([ + { + $group: { + _id: '$userId', + count: { $sum: 1 }, + }, + }, + { + $sort: { count: -1 }, + }, + { + $limit: 10, + }, + { + $lookup: { + from: 'user', + localField: '_id', + foreignField: '_id', + as: 'userData', + }, + }, + { + $unwind: '$userData', + }, + { + $project: { + userId: '$_id', + mobile: '$userData.mobile', + count: 1, + _id: 0, + }, + }, + ]); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', topUsers); + } catch (error) { + console.error('Error in topTenUsers:', error); + throw new BaseResponseDTO( + HttpStatus.INTERNAL_SERVER_ERROR, + 'Error fetching top ten users', + null, + ); + } + } + + async botAnswerReact( + dateRange: string, + status?: 'all' | 'bot' | 'online', + ) { + try { + // Split the date range + const [startDate, endDate] = dateRange.split('-'); + // Get date segments + const dateSegments = TimeHelper.divideDateRange(startDate, endDate); + + // Initialize data structure + const data = { + labels: dateSegments.map((segment) => ({ + start: segment.start, + end: segment.end, + })), + data: await Promise.all( + dateSegments.map(async (segment) => { + // Convert dates to ISO for MongoDB query + const startISO = TimeHelper.jalaliToISO(segment.start); + const endISO = TimeHelper.jalaliToISO(segment.end); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + // Build query for sessions created in date range + // Note: We count bot messages from ALL sessions, not just bot-only sessions, + // because sessions that connect to expert may still have bot messages that were rated + const query: any = { + createdISO: { + $gte: startDateObj, + $lte: endDateObj, + }, + }; + + // Apply status filter if provided + if (status === 'bot') { + query.connectedToExpert = false; + } else if (status === 'online') { + query.connectedToExpert = true; + } + // If status is 'all' or undefined, don't filter by connectedToExpert + + // Query sessions with messages field, use lean for performance + const sessions = await this.sessionModel + .find(query) + .select('messages') + .lean() + .exec(); + + // Count reactions + let positiveQuestions = 0; + let negativeQuestions = 0; + let noneAction = 0; + let totalBotMessages = 0; + + sessions.forEach((session) => { + session.messages?.forEach((message) => { + // Only process Bot messages + if (message.sender === 'Bot') { + totalBotMessages++; + // Normalize react value: trim whitespace and convert to lowercase + const react = message.react ? String(message.react).trim().toLowerCase() : null; + if (react === 'like') { + positiveQuestions++; + } else if (react === 'dislike') { + negativeQuestions++; + } else if (react === null || react === 'nothing') { + noneAction++; + } + } + }); + }); + + return { + start: segment.start, + end: segment.end, + positiveQuestions, + negativeQuestions, + noneAction, + totalBotMessages, + }; + }), + ), + }; + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); + } catch (err) { + console.error('Error in botAnswerReact:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async totalBotAnswersReact(dateRange?: string) { + try { + // Build query for sessions created in date range + // Note: We count bot messages from ALL sessions, not just bot-only sessions, + // because sessions that connect to expert may still have bot messages that were rated + const query: any = {}; + + // Parse date range if provided + if (dateRange) { + const [start, end] = dateRange.split('-'); + const startISO = TimeHelper.jalaliToISO(start); + const endISO = TimeHelper.jalaliToISO(end); + const startDate = new Date(startISO); + const endDate = new Date(endISO); + startDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + + // Filter sessions by createdISO (session creation date) + query.createdISO = { + $gte: startDate, + $lte: endDate, + }; + } + + // Query sessions with messages field, use lean for performance + const sessions = await this.sessionModel + .find(query) + .select('messages') + .lean() + .exec(); + + let totalBotMessages = 0; + let totalLikes = 0; + let totalDislikes = 0; + let totalRated = 0; // Count of bot messages that have been rated (like or dislike) + + // Count all bot messages from sessions created in date range + sessions.forEach((session) => { + session.messages?.forEach((message) => { + if (message.sender === 'Bot') { + totalBotMessages++; // Count all bot messages + + // Normalize react value: trim whitespace and convert to lowercase + // This ensures we correctly identify 'Like', 'like', 'Dislike', 'dislike', etc. + const react = message.react ? String(message.react).trim().toLowerCase() : null; + if (react === 'like') { + totalLikes++; + totalRated++; // Count as rated + } else if (react === 'dislike') { + totalDislikes++; + totalRated++; // Count as rated + } + } + }); + }); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + like: totalLikes, + dislike: totalDislikes, + rated: totalRated, // Total bot messages that were rated (like + dislike) + total: totalBotMessages, // Total bot messages (rated or not) + }); + } catch (err) { + console.error('Error in totalBotAnswersReact:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async totalConnectToExpertRate() { + try { + const sessions = await this.sessionModel + .find({}) + .select('connectedToExpert') + .lean() + .exec(); + + const totalSessions = sessions.length; + const connectedToExpert = sessions.filter( + (session) => session.connectedToExpert === true, + ).length; + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + totalSessions, + connectedToExpert, + }); + } catch (err) { + console.error('Error in totalConnectToExpertRate:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async activeUsers() { + try { + const totalActiveUsers = await this.userModel.countDocuments({}); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + totalActiveUsers, + }); + } catch (err) { + console.error('Error in activeUsers:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async totalSessions(dateRange?: string) { + try { + const query: any = {}; + + // Add date filter if provided + if (dateRange) { + const [startDate, endDate] = dateRange.split('-'); + const startISO = TimeHelper.jalaliToISO(startDate); + const endISO = TimeHelper.jalaliToISO(endDate); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + query.createdISO = { + $gte: startDateObj, + $lte: endDateObj, + }; + } + + // Count all sessions matching the query + const totalSessions = await this.sessionModel.countDocuments(query); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + totalSessions, + dateRange: dateRange || 'all', + }); + } catch (err) { + console.error('Error in totalSessions:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async expertMetrics() { + try { + const sessions = await this.sessionModel.find({}); + + let totalConnectedToExpert = 0; + let expertResponded = 0; + let totalResponseTime = 0; + let sessionsWithResponseTime = 0; + let totalWaitingTime = 0; + let sessionsWithExpertMessage = 0; + + sessions.forEach((session) => { + if (session.connectedToExpert) { + totalConnectedToExpert++; + + // Calculate expertResponseRate and avgResponseTime + if (session.onlineStartDate) { + expertResponded++; + if (session.onlineEndDate) { + const duration = Math.abs( + session.onlineEndDate.getTime() - + session.onlineStartDate.getTime(), + ); + totalResponseTime += duration; + sessionsWithResponseTime++; + } + } + } + + // Calculate avgUserWaitTime (similar to avgWaitingTime function) + if (session.onlineStartDate) { + const onlineStartDate = new Date(session.onlineStartDate); + + const firstExpertMessage = session.messages.find( + (message) => message.sender?.toLowerCase() === 'expert', + ); + + if (firstExpertMessage) { + const expertMessageDate = new Date(firstExpertMessage.createdISO); + const waitingTimeInSeconds = (expertMessageDate.getTime() - onlineStartDate.getTime()) / 1000; + totalWaitingTime += waitingTimeInSeconds; + sessionsWithExpertMessage++; + } + } + }); + + const expertResponseRate = totalConnectedToExpert > 0 ? (expertResponded / totalConnectedToExpert) * 100 : 0; + + const avgResponseTime = sessionsWithResponseTime > 0 ? (totalResponseTime / sessionsWithResponseTime / 60000) : 0; // in minutes + + const avgUserWaitTime = sessionsWithExpertMessage > 0 ? (totalWaitingTime / sessionsWithExpertMessage / 60) : 0; // in minutes + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + expertResponseRate: parseFloat(expertResponseRate.toFixed(2)), + avgResponseTime: parseFloat(avgResponseTime.toFixed(2)), + avgUserWaitTime: parseFloat(avgUserWaitTime.toFixed(2)), + }); + } catch (err) { + console.error('Error in expertMetrics:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async exportExpertReportsToExcel( + dateRange?: string, + experts?: string[], + rate?: string, + search?: string, + ): Promise { + try { + // Fetch data from the 4 endpoints that frontend uses: + // 1. Total Expert Answering + const totalAnsweringData = await this.totalAnswering(dateRange, experts); + + // 2. Average Expert Answering (response time) + const avgAnsweringData = await this.avgAnswering(dateRange, experts); + + // 3. Average Waiting Time + const avgWaitingTimeData = await this.avgWaitingTime(dateRange, experts); + + // 4. Expert List with filters + // Note: Expert list doesn't use date/activityType filters like other reports + // Frontend only uses rate and search filters for the expert list + const adminIdentity = { userData: { role: 'admin' } }; // Mock admin identity for service call + const expertListData = await this.userManagementService.findExpertsByFilter( + adminIdentity, + { + rate, + search, + // Don't pass date/activityType to expert list - it has different filter requirements + }, + ); + + // Create a new workbook + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Chatbot V2 API'; + workbook.created = new Date(); + + // Extract data from the 4 endpoints + const totalData = totalAnsweringData.data as any[]; + const avgData = avgAnsweringData.data as any[]; + const waitingData = avgWaitingTimeData.data as any[]; + const expertsList = expertListData.data as any[]; + + // Calculate summary metrics matching frontend display + // میانگین پاسخگویی کارشناسان (Average Expert Response Time) + // Frontend calculates: sum of all averageResponseTime / number of experts + // Frontend code: reduce((a, c) => a + c.avgTime, 0) / (avgAnswering.length || 1) + // Just do simple math - sum all values and divide by count (ignore units) + let avgExpertResponseTimeSeconds = 0; + if (avgData.length > 0) { + // Sum all averageResponseTime values + const sumOfAverages = avgData.reduce((sum, item) => sum + item.averageResponseTime, 0); + // Divide by number of experts (simple average) + avgExpertResponseTimeSeconds = sumOfAverages / avgData.length; + } + + // میانگین زمان انتظار کاربران (Average User Waiting Time) + // From avgWaitingTime endpoint - already in SECONDS + const avgUserWaitingTimeSeconds = waitingData.length > 0 + ? waitingData.reduce((sum, item) => sum + item.avgWaitingTime, 0) / waitingData.length + : 0; + + // تعداد کل پاسخ‌ها (Total Responses) + // From totalAnswering endpoint + const totalResponses = totalData.reduce((sum, item) => sum + item.answered, 0); + const totalNotAnswered = totalData.reduce((sum, item) => sum + item.notAnswered, 0); + const totalSessions = totalResponses + totalNotAnswered; + + // Calculate response rate percentage (answered / total * 100) + const responseRatePercentage = totalSessions > 0 + ? ((totalResponses / totalSessions) * 100).toFixed(1) + : '0.0'; + + // === Sheet 1: Key Metrics Summary === + const summarySheet = workbook.addWorksheet('📊 Key Metrics'); + + // Title + summarySheet.mergeCells('A1:D1'); + summarySheet.getCell('A1').value = 'Expert Performance Dashboard'; + summarySheet.getCell('A1').font = { size: 20, bold: true, color: { argb: 'FFFFFFFF' } }; + summarySheet.getCell('A1').fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF203864' }, + }; + summarySheet.getCell('A1').alignment = { horizontal: 'center', vertical: 'middle' }; + summarySheet.getRow(1).height = 40; + + // Filter info + let currentRow = 3; + if (dateRange) { + summarySheet.mergeCells(`A${currentRow}:D${currentRow}`); + summarySheet.getCell(`A${currentRow}`).value = `📅 Date Range: ${dateRange}`; + summarySheet.getCell(`A${currentRow}`).font = { size: 12, bold: true }; + summarySheet.getCell(`A${currentRow}`).alignment = { horizontal: 'center' }; + currentRow++; + } + if (experts && experts.length > 0) { + summarySheet.mergeCells(`A${currentRow}:D${currentRow}`); + summarySheet.getCell(`A${currentRow}`).value = `👥 Filtered Experts: ${experts.join(', ')}`; + summarySheet.getCell(`A${currentRow}`).font = { size: 11, italic: true }; + summarySheet.getCell(`A${currentRow}`).alignment = { horizontal: 'center' }; + currentRow++; + } + + currentRow += 1; + + // Key Metrics Cards + const metricsStartRow = currentRow; + + // Row 1: Metric Labels + summarySheet.getCell(`A${metricsStartRow}`).value = 'میانگین پاسخگویی کارشناسان'; + summarySheet.getCell(`B${metricsStartRow}`).value = 'میانگین زمان انتظار کاربران'; + summarySheet.getCell(`C${metricsStartRow}`).value = 'تعداد کل پاسخ‌ها'; + summarySheet.getCell(`D${metricsStartRow}`).value = 'نرخ پاسخگویی'; + + summarySheet.getRow(metricsStartRow).font = { bold: true, size: 11 }; + summarySheet.getRow(metricsStartRow).alignment = { horizontal: 'center', vertical: 'middle' }; + summarySheet.getRow(metricsStartRow).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE7E6E6' }, + }; + + // Row 2: Metric Values (matching frontend exactly) + const valuesRow = metricsStartRow + 1; + summarySheet.getCell(`A${valuesRow}`).value = `${avgExpertResponseTimeSeconds.toFixed(1)} ثانیه`; + summarySheet.getCell(`B${valuesRow}`).value = `${avgUserWaitingTimeSeconds.toFixed(1)} ثانیه`; + summarySheet.getCell(`C${valuesRow}`).value = totalResponses; + summarySheet.getCell(`D${valuesRow}`).value = `${responseRatePercentage}%`; + + summarySheet.getRow(valuesRow).font = { bold: true, size: 16 }; + summarySheet.getRow(valuesRow).alignment = { horizontal: 'center', vertical: 'middle' }; + summarySheet.getRow(valuesRow).height = 30; + + // Color code the values + summarySheet.getCell(`A${valuesRow}`).font = { ...summarySheet.getCell(`A${valuesRow}`).font, color: { argb: 'FF4472C4' } }; + summarySheet.getCell(`B${valuesRow}`).font = { ...summarySheet.getCell(`B${valuesRow}`).font, color: { argb: 'FF70AD47' } }; + summarySheet.getCell(`C${valuesRow}`).font = { ...summarySheet.getCell(`C${valuesRow}`).font, color: { argb: 'FFFFC000' } }; + summarySheet.getCell(`D${valuesRow}`).font = { ...summarySheet.getCell(`D${valuesRow}`).font, color: { argb: 'FFE74C3C' } }; + + // Additional Summary Stats + currentRow = valuesRow + 3; + summarySheet.getCell(`A${currentRow}`).value = 'Summary Statistics'; + summarySheet.getCell(`A${currentRow}`).font = { bold: true, size: 14 }; + currentRow++; + + const statsData = [ + ['Total Experts', expertsList.length], + ['Total Sessions', totalSessions], + ['Answered Sessions', totalResponses], + ['Not Answered Sessions', totalNotAnswered], + ['Response Rate', `${responseRatePercentage}%`], + ['Average Expert Rating', expertsList.length > 0 ? (expertsList.reduce((sum, e) => sum + (e.avgRate || 0), 0) / expertsList.length).toFixed(2) : 'N/A'], + ]; + + statsData.forEach(([label, value]) => { + summarySheet.getCell(`A${currentRow}`).value = label; + summarySheet.getCell(`B${currentRow}`).value = value; + summarySheet.getCell(`A${currentRow}`).font = { bold: true }; + // Center align + summarySheet.getRow(currentRow).alignment = { horizontal: 'center', vertical: 'middle' }; + currentRow++; + }); + + summarySheet.columns.forEach((column) => { + column.width = 30; + }); + + // === Sheet 2: Expert List with Details === + const expertSheet = workbook.addWorksheet('👥 Experts List'); + + expertSheet.mergeCells('A1:L1'); + expertSheet.getCell('A1').value = 'Expert List with Performance Metrics'; + expertSheet.getCell('A1').font = { size: 16, bold: true }; + expertSheet.getCell('A1').alignment = { horizontal: 'center' }; + + if (dateRange) { + expertSheet.mergeCells('A2:L2'); + expertSheet.getCell('A2').value = `Date Range: ${dateRange}`; + expertSheet.getCell('A2').font = { size: 12 }; + expertSheet.getCell('A2').alignment = { horizontal: 'center' }; + } + + const expertHeaderRow = dateRange ? 4 : 3; + const expertHeaders = ['Mobile', 'Name', 'Family', 'Username', 'Total Sessions', 'Avg Rate', 'Admin Rate', 'Likes', 'Dislikes', 'First Session', 'Last Session', 'Status']; + const expertHeaderRowObj = expertSheet.getRow(expertHeaderRow); + expertHeaderRowObj.values = expertHeaders; + expertHeaderRowObj.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + expertHeaderRowObj.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF4472C4' }, + }; + expertHeaderRowObj.alignment = { horizontal: 'center', vertical: 'middle' }; + + expertsList.forEach((expert, index) => { + const row = expertSheet.getRow(expertHeaderRow + 1 + index); + + // Format dates properly + const formatDate = (dateValue: any) => { + if (!dateValue) return 'N/A'; + try { + const date = new Date(dateValue); + if (isNaN(date.getTime())) return 'N/A'; + // Format as YYYY-MM-DD HH:mm:ss + return date.toISOString().replace('T', ' ').substring(0, 19); + } catch { + return 'N/A'; + } + }; + + row.values = [ + expert.mobile, + expert.name, + expert.family, + expert.username, + expert.totalSessions, + expert.avgRate, + expert.adminRate || 'N/A', + expert.like || 0, + expert.dislike || 0, + formatDate(expert.firstSession), + formatDate(expert.lastSession), + expert.isActive ? 'Active' : 'Inactive', + ]; + + // Center align all cells in this row + row.alignment = { horizontal: 'center', vertical: 'middle' }; + + // Color code status + const statusCell = row.getCell(12); + if (expert.isActive) { + statusCell.font = { color: { argb: 'FF70AD47' }, bold: true }; + } else { + statusCell.font = { color: { argb: 'FFE74C3C' }, bold: true }; + } + }); + + expertSheet.columns.forEach((column) => { + column.width = 18; + }); + + // === Sheet 3: Total Expert Answering === + const sheet1 = workbook.addWorksheet('Total Expert Answering'); + + // Add title + sheet1.mergeCells('A1:G1'); + sheet1.getCell('A1').value = 'Total Expert Answering Report'; + sheet1.getCell('A1').font = { size: 16, bold: true }; + sheet1.getCell('A1').alignment = { horizontal: 'center' }; + + // Add date range if provided + if (dateRange) { + sheet1.mergeCells('A2:G2'); + sheet1.getCell('A2').value = `Date Range: ${dateRange}`; + sheet1.getCell('A2').font = { size: 12 }; + sheet1.getCell('A2').alignment = { horizontal: 'center' }; + } + + // Add headers + const headerRow = dateRange ? 4 : 3; + const headers1 = ['Expert', 'First Name', 'Last Name', 'Answered', 'Not Answered', 'Total', 'Fastest (seconds)']; + const headerRow1 = sheet1.getRow(headerRow); + headerRow1.values = headers1; + headerRow1.font = { bold: true }; + headerRow1.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF4472C4' }, + }; + headerRow1.alignment = { horizontal: 'center', vertical: 'middle' }; + + // Add data + totalData.forEach((item, index) => { + const row = sheet1.getRow(headerRow + 1 + index); + row.values = [ + item.expert, + item.firstName, + item.lastName, + item.answered, + item.notAnswered, + item.total, + item.fastest, + ]; + // Center align all cells + row.alignment = { horizontal: 'center', vertical: 'middle' }; + }); + + // Auto-fit columns + sheet1.columns.forEach((column) => { + column.width = 20; + }); + + // Add chart for Total Answering + const chartStartRow = headerRow + totalData.length + 3; + sheet1.getCell(`A${chartStartRow}`).value = 'Summary Chart:'; + sheet1.getCell(`A${chartStartRow}`).font = { bold: true, size: 12 }; + + // Add a simple bar chart representation (text-based since ExcelJS charts are complex) + const chartDataRow = chartStartRow + 1; + sheet1.getCell(`A${chartDataRow}`).value = 'Expert'; + sheet1.getCell(`B${chartDataRow}`).value = 'Answered'; + sheet1.getCell(`C${chartDataRow}`).value = 'Not Answered'; + sheet1.getRow(chartDataRow).font = { bold: true }; + sheet1.getRow(chartDataRow).alignment = { horizontal: 'center', vertical: 'middle' }; + + totalData.forEach((item, index) => { + const row = sheet1.getRow(chartDataRow + 1 + index); + row.values = [item.expert, item.answered, item.notAnswered]; + // Center align all cells + row.alignment = { horizontal: 'center', vertical: 'middle' }; + }); + + // === Sheet 2: Average Expert Answering === + const sheet2 = workbook.addWorksheet('Average Expert Answering'); + + // Add title + sheet2.mergeCells('A1:E1'); + sheet2.getCell('A1').value = 'Average Expert Response Time Report'; + sheet2.getCell('A1').font = { size: 16, bold: true }; + sheet2.getCell('A1').alignment = { horizontal: 'center' }; + + // Add date range if provided + if (dateRange) { + sheet2.mergeCells('A2:E2'); + sheet2.getCell('A2').value = `Date Range: ${dateRange}`; + sheet2.getCell('A2').font = { size: 12 }; + sheet2.getCell('A2').alignment = { horizontal: 'center' }; + } + + // Add headers + const headerRow2 = dateRange ? 4 : 3; + const headers2 = ['First Name', 'Last Name', 'Average Response Time', 'Unit', 'Total Sessions']; + const headerRow2Obj = sheet2.getRow(headerRow2); + headerRow2Obj.values = headers2; + headerRow2Obj.font = { bold: true }; + headerRow2Obj.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF70AD47' }, + }; + headerRow2Obj.alignment = { horizontal: 'center', vertical: 'middle' }; + + // Add data + avgData.forEach((item, index) => { + const row = sheet2.getRow(headerRow2 + 1 + index); + row.values = [ + item.firstName, + item.lastName, + item.averageResponseTime, + item.averageResponseTimeUnit, + item.totalSessions, + ]; + // Center align all cells + row.alignment = { horizontal: 'center', vertical: 'middle' }; + }); + + // Auto-fit columns + sheet2.columns.forEach((column) => { + column.width = 20; + }); + + // === Sheet 3: Average Waiting Time === + const sheet3 = workbook.addWorksheet('Average Waiting Time'); + + // Add title + sheet3.mergeCells('A1:C1'); + sheet3.getCell('A1').value = 'Average Waiting Time Report'; + sheet3.getCell('A1').font = { size: 16, bold: true }; + sheet3.getCell('A1').alignment = { horizontal: 'center' }; + + // Add date range if provided + if (dateRange) { + sheet3.mergeCells('A2:C2'); + sheet3.getCell('A2').value = `Date Range: ${dateRange}`; + sheet3.getCell('A2').font = { size: 12 }; + sheet3.getCell('A2').alignment = { horizontal: 'center' }; + } + + // Add headers + const headerRow3 = dateRange ? 4 : 3; + const headers3 = ['First Name', 'Last Name', 'Average Waiting Time (seconds)']; + const headerRow3Obj = sheet3.getRow(headerRow3); + headerRow3Obj.values = headers3; + headerRow3Obj.font = { bold: true }; + headerRow3Obj.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFFFC000' }, + }; + headerRow3Obj.alignment = { horizontal: 'center', vertical: 'middle' }; + + // Add data + waitingData.forEach((item, index) => { + const row = sheet3.getRow(headerRow3 + 1 + index); + row.values = [item.firstName, item.lastName, item.avgWaitingTime]; + // Center align all cells + row.alignment = { horizontal: 'center', vertical: 'middle' }; + }); + + // Auto-fit columns + sheet3.columns.forEach((column) => { + column.width = 30; + }); + + // Generate buffer + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + } catch (err) { + console.error('Error in exportExpertReportsToExcel:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Failed to generate Excel file', + null, + ); + } + } +} diff --git a/src/scripts/redis-normalize.ts b/src/scripts/redis-normalize.ts new file mode 100644 index 0000000..3056746 --- /dev/null +++ b/src/scripts/redis-normalize.ts @@ -0,0 +1,178 @@ +import 'reflect-metadata'; +import mongoose, { Model, Schema, Types } from 'mongoose'; +import { AdminModel, AdminSchema } from '../database/model/admin.model'; +import { SessionModel, SessionSchema } from '../database/model/sessions.model'; +import { config } from 'dotenv'; +import Redis from 'ioredis'; + +config({ path: '.local.env' }); + +type RoomRedisData = { + roomId: string; + sessionId: string; + userId: string; + expertId: string | null; + createdAt: [string, string]; + messages: any[]; + isActive: boolean; +}; + +const ROOMS_HASH_KEY = 'rooms:active'; + +function isEmailLike(id: string | null | undefined): boolean { + return !!id && /@/.test(id); +} + +function redactUri(uri: string) { + return uri.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:****@'); +} + +async function connectMongo() { + const uri = process.env.DATABASE_URL + || `mongodb://${process.env.MONGO_INITDB_ROOT_USERNAME}:${process.env.MONGO_INITDB_ROOT_PASSWORD}` + + `@${process.env.MONGO_HOST}:${process.env.MONGO_PORT}/chat_bot?authMechanism=DEFAULT&authSource=admin`; + console.log('[redis-normalize] Connecting to Mongo:', redactUri(uri)); + await mongoose.connect(uri); +} + +async function main() { + const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; + console.log('[redis-normalize] Connecting to Redis:', redisUrl); + const redis = new Redis(redisUrl); + try { + const pong = await redis.ping(); + console.log('[redis-normalize] Redis PING:', pong); + } catch (e) { + console.error('[redis-normalize] Redis connection error:', e); + throw e; + } + + await connectMongo(); + + // Initialize mongoose models for standalone script + const Admin = (mongoose.models.admin || mongoose.model('admin', AdminSchema)) as Model; + const Session = (mongoose.models.sessions || mongoose.model('sessions', SessionSchema)) as Model; + + const summary = { + roomsTotal: 0, + fixedExpertIds: 0, + removedRoomsOrphans: 0, + removedSessionKeys: 0, + movedSessionSets: 0, + }; + + // scan all rooms + const roomsRaw = await redis.hgetall(ROOMS_HASH_KEY); + summary.roomsTotal = Object.keys(roomsRaw).length; + console.log('[redis-normalize] rooms:active count =', summary.roomsTotal); + + for (const [roomKey, raw] of Object.entries(roomsRaw)) { + let room: RoomRedisData | null = null; + try { + room = JSON.parse(raw) as RoomRedisData; + } catch { + // corrupt JSON → delete + await redis.hdel(ROOMS_HASH_KEY, roomKey); + summary.removedRoomsOrphans++; + continue; + } + + const sessionId = String(room.sessionId); + + // Validate session exists in Mongo + const sessExists = await Session.exists({ _id: sessionId }); + if (!sessExists) { + await redis.hdel(ROOMS_HASH_KEY, roomKey); + // cleanup session:* keys + const skeys = await redis.scan(0, 'MATCH', `session:${sessionId}:*`, 'COUNT', 10000).then(([, keys]) => keys as string[]); + for (const k of skeys) { + await redis.del(k); + summary.removedSessionKeys++; + } + summary.removedRoomsOrphans++; + continue; + } + + const expertId = room.expertId ? String(room.expertId) : ''; + + // If expertId missing or email-like, try to map to Admin _id + if (!expertId || isEmailLike(expertId)) { + const mappedAdmin = expertId + ? await Admin.findOne({ $or: [{ email: expertId }, { username: expertId }] }).select('_id').lean() + : null; + + if (!mappedAdmin) { + // Cannot map → treat as orphan assignment → remove room and cleanup + await redis.hdel(ROOMS_HASH_KEY, roomKey); + const skeys = await redis.scan(0, 'MATCH', `session:${sessionId}:*`, 'COUNT', 10000).then(([, keys]) => keys as string[]); + for (const k of skeys) { + await redis.del(k); + summary.removedSessionKeys++; + } + summary.removedRoomsOrphans++; + continue; + } + + // map to ObjectId + const newExpertId = (mappedAdmin as any)._id.toString(); + room.expertId = newExpertId as any; + await redis.hset(ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + summary.fixedExpertIds++; + + // ensure session is present in canonical expert set + const setKey = `expert:${newExpertId}:sessions`; + const added = await redis.sadd(setKey, sessionId); + if (added) summary.movedSessionSets++; + } else { + // ExpertId is an ObjectId-looking string; verify it matches an admin + const isOid = Types.ObjectId.isValid(expertId); + if (!isOid) { + // invalid expert id → remove room & keys + await redis.hdel(ROOMS_HASH_KEY, roomKey); + const skeys = await redis.scan(0, 'MATCH', `session:${sessionId}:*`, 'COUNT', 10000).then(([, keys]) => keys as string[]); + for (const k of skeys) { + await redis.del(k); + summary.removedSessionKeys++; + } + summary.removedRoomsOrphans++; + continue; + } + const admin = await Admin.findById(expertId).select('_id').lean(); + if (!admin) { + // invalid reference → remove room & aux keys + await redis.hdel(ROOMS_HASH_KEY, roomKey); + const skeys = await redis.scan(0, 'MATCH', `session:${sessionId}:*`, 'COUNT', 10000).then(([, keys]) => keys as string[]); + for (const k of skeys) { + await redis.del(k); + summary.removedSessionKeys++; + } + summary.removedRoomsOrphans++; + continue; + } + } + } + + // Final pass: remove expert sets pointing to non-existing sessions + const [_, expertSetKeys] = await redis.scan(0, 'MATCH', 'expert:*:sessions', 'COUNT', 10000); + for (const setKey of expertSetKeys as string[]) { + const members = await redis.smembers(setKey); + for (const sid of members) { + const exists = await redis.hexists(ROOMS_HASH_KEY, `room:${sid}`); + if (!exists) { + await redis.srem(setKey, sid); + summary.movedSessionSets++; + } + } + } + + console.log('[redis-normalize] Summary:', summary); + await redis.quit(); + await mongoose.disconnect(); +} + +main().catch((err) => { + console.error('[redis-normalize] Error:', err); + process.exit(1); +}); + + diff --git a/src/socket/support-management/chat.gateway.ts b/src/socket/support-management/chat.gateway.ts new file mode 100644 index 0000000..7b0a161 --- /dev/null +++ b/src/socket/support-management/chat.gateway.ts @@ -0,0 +1,1661 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + MessageBody, + ConnectedSocket, + OnGatewayInit, + OnGatewayConnection, + OnGatewayDisconnect, + } from '@nestjs/websockets'; + + import { Server, Socket } from 'socket.io'; +import { + JoinRoomPayload, + LeaveRoomPayload, + ExpertOnlinePayload, + RequestExpertPayload, + SendMessagePayload, + GetChatHistoryPayload, + EndChatPayload, + NotificationPayload, + GetTransferCandidatesPayload, + TransferChatPayload, + createSocketResponse, // Import the new helper +} from './dto/eventPayloads.dto'; + +import { ChatService, TransferChatResult } from './chat.service'; +import { HttpStatus, Injectable, UsePipes, ValidationPipe, UseFilters, ForbiddenException, BadRequestException } from '@nestjs/common'; // Import HttpStatus +import { InjectModel } from '@nestjs/mongoose'; +import { UserModel } from 'src/database/model/user.model'; +import { Model, Types } from 'mongoose'; +import { AdminModel } from 'src/database/model/admin.model'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { RedisService } from "src/common/helpers/redis.service"; +import { WsAllExceptionsFilter } from 'src/common/filters/ws-all-exceptions.filter'; +import { BusinessHoursService } from 'src/business-hours/business-hours.service'; +import { StructuredLogger, LogCategory } from 'src/common/helpers/structured-logger.service'; +import { AuditLogService } from 'src/common/services/audit-log.service'; + +@Injectable() +@WebSocketGateway({ + cors: { + origin: '*', // ⚠️ Adjust in production (restrict to your domains) + }, +}) +@UsePipes(new ValidationPipe({ whitelist: true, transform: true })) +@UseFilters(new WsAllExceptionsFilter()) +export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() server: Server; + private readonly logger = new StructuredLogger(ChatGateway.name); + constructor( + private readonly chatService: ChatService, + @InjectModel(UserModel.name) private readonly userModel: Model, + @InjectModel(AdminModel.name) private readonly adminModel: Model, + @InjectModel(SessionModel.name) private readonly sessionModel: Model, + private readonly redisService: RedisService, + private readonly businessHoursService: BusinessHoursService, + private readonly auditLogService: AuditLogService, + + ) {} + private client() { + return this.redisService.getClient(); + } + // ----- Gateway Lifecycle ----- + afterInit() { + console.log('✅ Chat Gateway Initialized'); + this.startBackgroundWatchers(); + this.logger.log(LogCategory.BACKGROUND, 'Chat inactivity/reassignment watchers started'); + const checkIntervalMs = process.env.CHECK_INTERVAL_MS ? parseInt(process.env.CHECK_INTERVAL_MS as any, 10) * 1000 : 60 * 1000; + const repairSeconds = process.env.REPAIR_REDIS_INTERVAL_SECONDS ? parseInt(process.env.REPAIR_REDIS_INTERVAL_SECONDS as any, 10) : 600; + this.logger.log(LogCategory.INFO, 'Gateway initialized', { checkIntervalMs, repairSeconds }); + this.startRepairScheduler(); + } + + async handleConnection(client: Socket) { + const clientId = client.id; + this.logger.log(LogCategory.CONNECTION, 'Client connected', { clientId }); + // Audit log for connection + this.auditLogService.logWebSocketEvent('clientConnected', client, { + metadata: { clientId }, + }).catch(() => {}); // Fire and forget + } + + async handleDisconnect(client: Socket) { + try { + await this.chatService.handleDisconnect(client); + this.logger.log(LogCategory.DISCONNECT, 'Client disconnected', { clientId: client.id }); + // Audit log for disconnection + this.auditLogService.logWebSocketEvent('clientDisconnected', client, { + metadata: { clientId: client.id }, + }).catch(() => {}); // Fire and forget + } catch (err) { + this.logger.error(LogCategory.ERROR, 'handleDisconnect error', err as any, { clientId: client.id }); + } + } + // Automatically triggered background timers + private startBackgroundWatchers() { + // run every 60 seconds + const checkIntervalMs = process.env.CHECK_INTERVAL_MS ? parseInt(process.env.CHECK_INTERVAL_MS, 10) * 1000 : 60 * 1000; + setInterval(async () => { + try { + // 1) Reassign inactive experts + const reassignments = await this.chatService.checkAndReassignInactiveExperts(); + if (Array.isArray(reassignments) && reassignments.length) { + for (const r of reassignments) { + // Note: When no expert is available during reassignment, we don't close the chat immediately. + // The session remains active and will be closed by the inactivity timer (checkAndCloseInactiveChats) + // which will emit chatAutoClosed event. + + const roomId = `room:${r.sessionId}`; + + // Fetch expert basic info for client response (only family, not name) + let expertInfo: { family: string } | null = null; + if (r.newExpertId && Types.ObjectId.isValid(r.newExpertId)) { + const admin = await this.adminModel.findById(r.newExpertId, 'family').lean(); + if (admin) { + expertInfo = { family: (admin as any).family }; + } + } + + // notify room (user) and new expert if any + this.server.to(roomId).emit('response', createSocketResponse( + 'expertReassigned', + { sessionId: r.sessionId, oldExpertId: r.oldExpertId, newExpertId: r.newExpertId, ...(expertInfo ? { expert: expertInfo } : {}) }, + 'Expert reassigned due to inactivity', + HttpStatus.OK, + )); + if (r.newExpertId) { + // send a direct notification to new expert socket if available + // fetch expert raw to get socketId + const experts = await this.chatService.getOnlineExperts(); + const found = experts.find((e:any)=> e.expertId === r.newExpertId); + if (found?.socketId) { + this.server.to(found.socketId).emit('response', createSocketResponse( + 'newAutoAssignedSession', + { sessionId: r.sessionId, roomId }, + 'You have been auto-assigned a session', + HttpStatus.OK, + )); + } + } + } + } + + // 2) Auto-close inactive chats + const closed = await this.chatService.checkAndCloseInactiveChats(); + if (Array.isArray(closed) && closed.length) { + for (const { sessionId, userId } of closed) { + const roomId = `room:${sessionId}`; + const notification = createSocketResponse( + 'chatAutoClosed', + { sessionId, role: 'system' }, + 'Chat auto-closed due to inactivity', + HttpStatus.OK, + ); + + // Notify room (for users/experts in the room) + this.server.to(roomId).emit('response', notification); + + // Also send direct notification to user's socket if they're connected + // This ensures users receive the notification even if they're not in the room + if (userId) { + const userSocketId = this.chatService.getConnectedSocketId(userId); + if (userSocketId) { + this.server.to(userSocketId).emit('response', notification); + this.logger.log(LogCategory.CHAT_AUTO_CLOSE, 'Sent auto-close notification', { userId, socketId: userSocketId, sessionId }); + } + } + } + } + // 3) Auto-reassign sessions after expert disconnect grace period + const disconnectReassignments = + await this.chatService.checkAndReassignDisconnectedExperts(); + for (const r of disconnectReassignments) { + await this.notifyExpertReassigned(r.sessionId, r.oldExpertId, r.newExpertId, r.userId); + } + + // 4) Auto-offline inactive experts + // Get online experts with socket IDs before offlining them + const onlineExpertsBeforeOffline = await this.chatService.getOnlineExperts(); + const offlined = await this.chatService.checkAndOfflineInactiveExperts(); + if (Array.isArray(offlined) && offlined.length) { + this.logger.log(LogCategory.EXPERT_OFFLINE, 'Auto-offlined experts', { expertIds: offlined }); + // Send notification to each offlined expert's socket + for (const expertId of offlined) { + // Find the expert's socket ID from the list we got before offlining + const expert = onlineExpertsBeforeOffline.find((e: any) => e.expertId === expertId); + if (expert?.socketId) { + this.server.to(expert.socketId).emit('response', createSocketResponse( + 'expertAutoOfflined', + { expertId }, + 'You have been automatically set offline due to inactivity', + HttpStatus.OK, + )); + this.logger.log(LogCategory.EXPERT_OFFLINE, 'Sent auto-offline notification', { expertId, socketId: expert.socketId }); + } + } + } + } catch (err) { + this.logger.error(LogCategory.ERROR, 'Background watcher error', err as any); + } + }, checkIntervalMs); + + // Run junk room cleanup every hour + setInterval(async () => { + try { + await this.chatService.cleanupJunkRooms(); + } catch (err) { + this.logger.error(LogCategory.ERROR, 'Junk room cleanup error', err as any); + } + }, 60 * 5 * 1000); +} + +// Periodic Redis consistency repair +private startRepairScheduler() { + const seconds = process.env.REPAIR_REDIS_INTERVAL_SECONDS + ? parseInt(process.env.REPAIR_REDIS_INTERVAL_SECONDS as any, 10) + : 600; // default 10 minutes + const intervalMs = Math.max(60, seconds) * 1000; + setInterval(async () => { + try { + this.logger.log(LogCategory.REDIS_REPAIR, 'Repair tick started', { timestamp: new Date().toISOString(), cleanStale: true }); + const repaired = await this.chatService.repairRedisConsistency({ cleanStale: true }); + if (Array.isArray(repaired) && repaired.length) { + this.logger.warn(LogCategory.REDIS_REPAIR, 'Redis consistency repaired', { fixedCount: repaired.length, expertIds: repaired }); + } else { + this.logger.log(LogCategory.REDIS_REPAIR, 'No repairs needed'); + } + } catch (err) { + this.logger.error(LogCategory.ERROR, 'repairRedisConsistency scheduler error', err as any); + } + }, intervalMs); +} + +// // When a message is sent +// async onMessageReceived(sessionId: string, userId: string, message: string) { +// await this.chatService.updateLastActivity(sessionId); +// this.server.to(sessionId).emit('newMessage', { userId, message }); +// } + +// // When expert opens chat +// async onExpertSeen(sessionId: string, expertId: string) { +// await this.chatService.markSeen(sessionId, expertId); +// this.server.to(sessionId).emit('expertSeen', { expertId }); +// } + + // ----- Join / Leave Rooms ----- + @SubscribeMessage('joinRoom') + async onJoinRoom( + @MessageBody() payload: JoinRoomPayload, + @ConnectedSocket() client: Socket, + ) { + try { + // Check business hours - only for Users (not Experts) + if (payload?.role === 'User') { + const status = await this.businessHoursService.isOpen(); + if (!status.open) { + const nextOpen = await this.businessHoursService.getNextOpen(); + const message = status.reason || 'Online conversation is not available now. Please try again later.'; + return client.emit('response', createSocketResponse( + 'ONLINE_CONVERSATION_DISABLED', + { + code: 'ONLINE_CONVERSATION_DISABLED', + message, + nextOpen: nextOpen?.toISOString() || null, + }, + message, + HttpStatus.SERVICE_UNAVAILABLE, + )); + } + } + + // If the socket is already in this room, short-circuit with idempotent ack + const roomsSet = (client as any).rooms as Set; + if (payload?.roomId && roomsSet?.has(payload.roomId)) { + this.server + .to(client.id) + .emit('response', createSocketResponse( + 'alreadyJoined', + { success: true, roomId: payload.roomId, alreadyInRoom: true }, + 'Already in room', + HttpStatus.OK, + )); + this.logger.log(LogCategory.JOIN, 'User already in room', { userId: payload.userId, roomId: payload.roomId }); + return; + } + + // Check if session is closed (for Experts) - prevent joining closed sessions + if (payload?.role === 'Expert' && payload?.roomId) { + const sessionId = (payload.roomId || '').replace(/^room:/, ''); + if (sessionId && Types.ObjectId.isValid(sessionId)) { + try { + const session = await this.sessionModel.findById(sessionId).select('onlineChatClosed chatClosed').lean(); + if (session && (session.onlineChatClosed || session.chatClosed)) { + this.logger.warn(LogCategory.JOIN, 'Expert attempted to join closed session', { + expertId: payload.userId, + sessionId, + onlineChatClosed: session.onlineChatClosed, + chatClosed: session.chatClosed + }); + return client.emit('response', createSocketResponse( + 'sessionClosed', + { + sessionId, + roomId: payload.roomId, + reason: 'This chat session has been closed' + }, + 'Cannot join closed chat session', + HttpStatus.CONFLICT, + )); + } + } catch (err) { + this.logger.error(LogCategory.JOIN, 'Error checking session status on joinRoom', { sessionId }, err); + // Continue anyway - don't block join if check fails + } + } + } + + await this.chatService.registerClient(payload, client.id); + + // Join socket.io room + if (payload.roomId) client.join(payload.roomId); + + if (payload?.role === 'Expert' && payload?.roomId) { + // Mark OLD messages (sent before expert joined) as seen, but NOT new messages sent after join + const sessionId = (payload.roomId || '').replace(/^room:/, ''); + await this.chatService.cancelPendingExpertTimer(sessionId); + if (sessionId) { + // Capture timestamp when expert joins - only mark messages created BEFORE this time + const joinTimestamp = new Date(); + this.chatService.markMessagesSeenBeforeTimestamp(sessionId, 'expert', joinTimestamp).catch((err) => { + this.logger.warn(LogCategory.MESSAGE_SEEN, 'Auto markMessagesSeenBeforeTimestamp failed on joinRoom', { sessionId }, err); + }); + + // bump expert last active + if (payload.userId) { + await this.chatService.refreshExpertLastActive(payload.userId); + } + // ensure expert/session mapping exists so getExpertSessions reflects this session + if (payload.userId && Types.ObjectId.isValid(payload.userId)) { + await this.chatService.ensureExpertSessionMapping(payload.userId, sessionId); + } + } + } + + // If expert joins, fetch and include expert's family (case-insensitive role check) + let expertInfo: { family: string } | null = null; + if (payload?.role && typeof payload.role === 'string' && payload.role.toLowerCase() === 'expert') { + const expertId = payload.userId; + if (expertId && Types.ObjectId.isValid(expertId)) { + const admin = await this.adminModel.findById(expertId, 'family').lean(); + if (admin) { + expertInfo = { family: (admin as any).family }; + } + } + } + + this.server + .to(client.id) + .emit('response', createSocketResponse( + 'joinedRoom', + { success: true, roomId: payload.roomId, ...(expertInfo ? { expert: expertInfo } : {}) }, + 'Successfully joined room', + HttpStatus.OK, + )); + + this.logger.log(LogCategory.JOIN, 'User joined room', { userId: payload.userId, roomId: payload.roomId, role: payload.role }); + + // Audit log for join room + this.auditLogService.logWebSocketEvent('joinRoom', client, { + userId: payload.userId, + resource: 'Room', + resourceId: payload.roomId?.replace(/^room:/, '') || payload.roomId, + statusCode: HttpStatus.OK, + metadata: { role: payload.role, success: true }, + }).catch(() => {}); // Fire and forget + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onJoinRoom error', err as any, { userId: payload?.userId, roomId: payload?.roomId }); + + // Audit log for failed join room + this.auditLogService.logWebSocketEvent('joinRoom', client, { + userId: payload?.userId, + resource: 'Room', + resourceId: payload?.roomId?.replace(/^room:/, '') || payload?.roomId, + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + metadata: { role: payload?.role, error: (err as any)?.message, success: false }, + }).catch(() => {}); // Fire and forget + client.emit('response', createSocketResponse( + 'joinRoom', + {}, + 'Failed to join room', + HttpStatus.INTERNAL_SERVER_ERROR, + )); + } + } + + @SubscribeMessage('leave_room') + async handleLeaveRoom( + @ConnectedSocket() client: Socket, + @MessageBody() payload: LeaveRoomPayload, + ) { + await client.leave(payload.roomId); + this.chatService.unregisterClient(payload.userId); + const user = await this.userModel.findOne({_id:payload.userId}); + this.server.to(payload.roomId).emit( + 'response', + createSocketResponse( + 'leave_room', + payload, + `A ${payload.role} with mobile ${user.mobile} Left. Name: ${user.name} ${user.family}`, + HttpStatus.OK, + ), + ); + + // Audit log for leave room + this.auditLogService.logWebSocketEvent('leaveRoom', client, { + userId: payload.userId, + resource: 'Room', + resourceId: payload.roomId?.replace(/^room:/, '') || payload.roomId, + statusCode: HttpStatus.OK, + metadata: { role: payload.role }, + }).catch(() => {}); // Fire and forget + } + + // ----- Expert Online / Offline ----- + @SubscribeMessage('expertOnline') + async onExpertOnline( + @MessageBody() payload: ExpertOnlinePayload, + @ConnectedSocket() client: Socket, + ) { + try { + if (!payload?.expertId || !Types.ObjectId.isValid(payload.expertId) || /@/.test(String(payload.expertId))) { + return client.emit('response', createSocketResponse( + 'expertOnline', + {}, + 'Invalid expertId: must be a Mongo ObjectId', + HttpStatus.BAD_REQUEST, + )); + } + const assignments = await this.chatService.setExpertOnline(payload.expertId, client.id); + client.emit('response', createSocketResponse( + 'expertOnlineAck', + { success: true }, + 'Expert is online', + HttpStatus.OK, + )); + this.logger.log(LogCategory.EXPERT_ONLINE, 'Expert is online', { expertId: payload.expertId, socketId: client.id }); + + // Audit log for expert online + this.auditLogService.logWebSocketEvent('expertOnline', client, { + userId: payload.expertId, + resource: 'Expert', + resourceId: payload.expertId, + statusCode: HttpStatus.OK, + metadata: { socketId: client.id, assignmentsCount: assignments?.length || 0 }, + }).catch(() => {}); // Fire and forget + + // Touch last active + await this.chatService.refreshExpertLastActive(payload.expertId); + + // If any queued users were assigned because this expert came online, notify both sides now + if (Array.isArray(assignments) && assignments.length) { + for (const a of assignments) { + if (a.isTransfer) { + client.join(a.roomId); + await this.notifyExpertReassigned(a.sessionId, null, a.expertId, a.userId); + } else { + client.join(a.roomId); + client.emit('response', createSocketResponse( + 'newUserAssigned', + { userId: a.userId, roomId: a.roomId }, + 'New user assigned to expert', + HttpStatus.OK, + )); + + const userSocketId = this.chatService.getConnectedSocketId(a.userId); + if (userSocketId) { + this.server.sockets.sockets.get(userSocketId)?.join(a.roomId); + this.server.to(userSocketId).emit('response', createSocketResponse( + 'newUserAssigned', + { userId: a.userId, roomId: a.roomId, expertId: a.expertId }, + 'Expert assigned successfully', + HttpStatus.OK, + )); + } else { + this.server.to(a.roomId).emit('response', createSocketResponse( + 'expertAssigned', + { expertId: a.expertId, roomId: a.roomId }, + 'Expert assigned successfully', + HttpStatus.OK, + )); + } + } + + this.logger.log(LogCategory.USER_QUEUE, 'Queued user assigned to expert on expertOnline', { userId: a.userId, expertId: a.expertId, roomId: a.roomId }); + } + } + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onExpertOnline error', err as any, { expertId: payload?.expertId }); + client.emit('response', createSocketResponse( + 'expertOnline', + {}, + 'Failed to set expert online', + HttpStatus.INTERNAL_SERVER_ERROR, + )); + } + } + + @SubscribeMessage('expertOffline') + async onExpertOffline( + @MessageBody() payload: ExpertOnlinePayload, + @ConnectedSocket() client: Socket, + ) { + try { + if (!payload?.expertId || !Types.ObjectId.isValid(payload.expertId) || /@/.test(String(payload.expertId))) { + return client.emit('response', createSocketResponse( + 'expertOffline', + {}, + 'Invalid expertId: must be a Mongo ObjectId', + HttpStatus.BAD_REQUEST, + )); + } + await this.chatService.setExpertOffline(payload.expertId); + client.emit('response', createSocketResponse( + 'expertOfflineAck', + { success: true }, + 'Expert is offline', + HttpStatus.OK, + )); + this.logger.log(LogCategory.EXPERT_OFFLINE, 'Expert went offline', { expertId: payload.expertId }); + + // Audit log for expert offline + this.auditLogService.logWebSocketEvent('expertOffline', client, { + userId: payload.expertId, + resource: 'Expert', + resourceId: payload.expertId, + statusCode: HttpStatus.OK, + }).catch(() => {}); // Fire and forget + } catch (err) { + const status = + err instanceof ForbiddenException + ? HttpStatus.FORBIDDEN + : HttpStatus.INTERNAL_SERVER_ERROR; + const message = + err instanceof ForbiddenException + ? (err as ForbiddenException).message + : 'Failed to set expert offline'; + this.logger.error(LogCategory.ERROR, 'onExpertOffline error', err as any, { expertId: payload?.expertId }); + this.auditLogService.logWebSocketEvent('expertOffline', client, { + userId: payload?.expertId, + resource: 'Expert', + resourceId: payload?.expertId, + statusCode: status, + metadata: { error: (err as any)?.message }, + }).catch(() => {}); + client.emit('response', createSocketResponse('expertOffline', {}, message, status)); + } + } + + @SubscribeMessage('getTransferCandidates') + async onGetTransferCandidates( + @MessageBody() payload: GetTransferCandidatesPayload, + @ConnectedSocket() client: Socket, + ) { + try { + const candidates = await this.chatService.getTransferCandidates( + payload.sessionId, + payload.expertId, + ); + const canTransfer = candidates.length > 0; + client.emit( + 'response', + createSocketResponse( + 'transferCandidates', + { sessionId: payload.sessionId, canTransfer, candidates }, + canTransfer + ? 'Transfer candidates loaded' + : 'No experts available for transfer', + HttpStatus.OK, + ), + ); + } catch (err) { + const status = + err instanceof BadRequestException || err instanceof ForbiddenException + ? HttpStatus.BAD_REQUEST + : HttpStatus.INTERNAL_SERVER_ERROR; + client.emit( + 'response', + createSocketResponse( + 'getTransferCandidates', + {}, + (err as Error)?.message || 'Failed to load transfer candidates', + status, + ), + ); + } + } + + @SubscribeMessage('transferChat') + async onTransferChat( + @MessageBody() payload: TransferChatPayload, + @ConnectedSocket() client: Socket, + ) { + try { + const result = await this.chatService.transferChat({ + sessionId: payload.sessionId, + fromExpertId: payload.expertId, + mode: payload.mode, + targetExpertId: payload.targetExpertId, + reason: payload.reason, + }); + await this.handleTransferResult(result, client); + } catch (err) { + const status = + err instanceof BadRequestException || err instanceof ForbiddenException + ? HttpStatus.BAD_REQUEST + : HttpStatus.INTERNAL_SERVER_ERROR; + client.emit( + 'response', + createSocketResponse( + 'transferChat', + {}, + (err as Error)?.message || 'Transfer failed', + status, + ), + ); + } + } + + // ----- Matching ----- + @SubscribeMessage('requestExpert') + async onRequestExpert( + @MessageBody() payload: RequestExpertPayload, + @ConnectedSocket() client: Socket, + ) { + try { + // Check business hours first + const status = await this.businessHoursService.isOpen(); + if (!status.open) { + const nextOpen = await this.businessHoursService.getNextOpen(); + const message = status.reason || 'Online conversation is not available now. Please try again later.'; + return client.emit('response', createSocketResponse( + 'ONLINE_CONVERSATION_DISABLED', + { + code: 'ONLINE_CONVERSATION_DISABLED', + message, + nextOpen: nextOpen?.toISOString() || null, + }, + message, + HttpStatus.SERVICE_UNAVAILABLE, + )); + } + + if (!payload?.userId) { + client.emit('response', createSocketResponse( + 'requestExpert', + {}, + 'Missing userId', + HttpStatus.BAD_REQUEST, + )); + return; + } + + // Check if user has any active sessions and force close them before creating a new one + // This handles the case where user has an old active session but starts a new one + const closedSessions = await this.chatService.forceCloseUserActiveSessions(String(payload.userId)); + if (closedSessions.length > 0) { + this.logger.log(LogCategory.CHAT_CLOSE, 'Force closed previous active sessions', { userId: payload.userId, closedCount: closedSessions.length, sessionIds: closedSessions }); + + // Notify user that previous session was closed + for (const closedSessionId of closedSessions) { + const roomId = `room:${closedSessionId}`; + // Notify room participants (if any are still connected) + this.server.to(roomId).emit('response', createSocketResponse( + 'chatEnded', + { sessionId: closedSessionId, reason: 'Previous session closed due to new expert request', role: 'user' }, + 'Previous chat session ended', + HttpStatus.OK, + )); + } + } + + console.log(payload) + // Pass business hours status to assignExpert + const assignment = await this.chatService.assignExpert(payload, status.open); + console.log(assignment) + + // Handle different assignment results + if (assignment.status === 'queued') { + const position = await this.chatService.getWaitingPosition( + payload.userId, + ); + client.emit('response', createSocketResponse( + 'queued', + { position }, + 'All experts are busy. You are in queue.', + HttpStatus.SERVICE_UNAVAILABLE, + )); + + // Audit log for queued request + this.auditLogService.logWebSocketEvent('requestExpert', client, { + userId: payload.userId, + resource: 'Chat', + statusCode: HttpStatus.SERVICE_UNAVAILABLE, + metadata: { queued: true, position }, + }).catch(() => {}); // Fire and forget + + return; + } + + // Handle case when no experts are available during business hours + if (assignment.status === 'no_experts_available') { + client.emit('response', createSocketResponse( + 'NO_EXPERTS_AVAILABLE', + { + code: 'NO_EXPERTS_AVAILABLE', + message: 'No experts are currently available. Please try again later.', + }, + 'No experts are currently available. Please try again later.', + HttpStatus.SERVICE_UNAVAILABLE, + )); + + // Audit log for no experts available + this.auditLogService.logWebSocketEvent('requestExpert', client, { + userId: payload.userId, + resource: 'Chat', + statusCode: HttpStatus.SERVICE_UNAVAILABLE, + metadata: { noExpertsAvailable: true }, + }).catch(() => {}); // Fire and forget + + return; + } + + // Successful assignment + const { roomId, expertId, socketId } = assignment; + + // Notify both sides + client.join(roomId); + this.server + .to(socketId) + .emit('response', createSocketResponse( + 'newUserAssigned', + { userId: payload.userId, roomId }, + 'New user assigned to expert', + HttpStatus.OK, + )); + + // Fetch expert basic info for client response + let expertInfo: { family: string } | null = null; + if (expertId && Types.ObjectId.isValid(expertId)) { + const admin = await this.adminModel.findById(expertId, 'family').lean(); + if (admin) { + expertInfo = { family: (admin as any).family }; + } + } + + client.emit('response', createSocketResponse( + roomId, + { roomId, expertId, ...(expertInfo ? { expert: expertInfo } : {}) }, + 'Expert assigned successfully', + HttpStatus.OK, + )); + + this.logger.log(LogCategory.CHAT_START, 'User connected to expert', { userId: payload.userId, expertId, roomId }); + + // Audit log for request expert (successful assignment) + this.auditLogService.logWebSocketEvent('requestExpert', client, { + userId: payload.userId, + resource: 'Chat', + resourceId: roomId?.replace(/^room:/, '') || roomId, + newValues: { expertId, roomId, assigned: true }, + statusCode: HttpStatus.OK, + metadata: { expertId, roomId }, + }).catch(() => {}); // Fire and forget + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onRequestExpert error', err as any, { userId: payload?.userId }); + + // Audit log for failed request expert + this.auditLogService.logWebSocketEvent('requestExpert', client, { + userId: payload?.userId, + resource: 'Chat', + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + metadata: { error: (err as any)?.message }, + }).catch(() => {}); // Fire and forget + client.emit('response', createSocketResponse( + 'requestExpert', + {}, + 'Failed to request expert', + HttpStatus.INTERNAL_SERVER_ERROR, + )); + } + } + + // ----- Messaging ----- + @SubscribeMessage('sendMessage') +async onSendMessage( + @MessageBody() payload: SendMessagePayload, + @ConnectedSocket() client: Socket, +) { + try { + // Normalize/require sessionId + const canonicalSessionId = payload.sessionId || ( + payload.roomId + ? (payload.roomId.startsWith('room:') ? payload.roomId.replace(/^room:/, '') : payload.roomId) + : '' + ); + if (!canonicalSessionId) { + this.logger.warn(LogCategory.MESSAGE_SEND, 'Missing sessionId and unparseable roomId', { payloadRoomId: payload.roomId }); + return client.emit('response', createSocketResponse('sendMessage', {}, 'Missing sessionId', HttpStatus.BAD_REQUEST)); + } + + const roomId = `room:${canonicalSessionId}`; + if (payload.roomId && payload.roomId !== roomId) { + this.logger.warn(LogCategory.MESSAGE_SEND, 'RoomId mismatch, using canonical', { payloadRoomId: payload.roomId, canonicalRoomId: roomId }); + } + + const normalizedPayload: SendMessagePayload = { + ...payload, + sessionId: canonicalSessionId, + roomId, + } as any; + + // Save the message via service (this now also persists to Mongo) + const savedMessage = await this.chatService.saveMessage(normalizedPayload); + const isExpertMessage = String(payload.senderRole).toLowerCase() === 'expert'; + await this.chatService.refreshInactiveChatTimer(canonicalSessionId, isExpertMessage); + // If expert sent the message, bump expert last active + if (isExpertMessage && payload.senderId) { + await this.chatService.refreshExpertLastActive(String(payload.senderId)); + } + if (payload.roomId && payload.roomId !== roomId) { + this.logger.warn(LogCategory.MESSAGE_SEND, 'RoomId mismatch, using canonical', { payloadRoomId: payload.roomId, canonicalRoomId: roomId }); + } + + // 1) Acknowledge the sender with messageAck (contains savedMessage) + client.emit('messageAck', { + status: 'ok', + event: 'messageAck', + data: { ...savedMessage, roomId, sessionId: canonicalSessionId }, + }); + + // 2) Broadcast newMessage to all other members in the room (EXCLUDE sender) + client.to(roomId).emit( + 'response', + createSocketResponse('newMessage', { + roomId, + sessionId: canonicalSessionId, + senderId: payload.senderId, + senderRole: payload.senderRole, + message: { ...savedMessage }, + }), +); + +const members = this.server.sockets.adapter.rooms.get(roomId) || new Set(); +this.logger.debug(LogCategory.ROOM, 'Room members', { roomId, memberCount: members.size, members: [...members] }); + const isExpertViewer = payload.senderRole === 'User'; + const isUserViewer = payload.senderRole === 'Expert'; + if (isExpertViewer) { + // ❌ REMOVED: Auto-mark when user sends message doesn't make sense + // Expert should explicitly mark messages as seen or send a message to indicate they've seen them + // When expert sends a message, previous messages are marked as seen (see isUserViewer block below) + + // Fallback delivery: if expert socket is not in the room, emit directly to expert socket + try { + const roomDetails: any = await this.chatService.getRoomDetails(roomId); + const expertId = String(roomDetails?.expert?._id || roomDetails?.expertId || ''); + if (expertId) { + const experts = await this.chatService.getOnlineExperts(); + const target = experts.find((e: any) => e.expertId === expertId); + if (target?.socketId && !members.has(target.socketId)) { + this.server.to(target.socketId).emit('response', createSocketResponse( + 'newMessage', + { + roomId, + sessionId: canonicalSessionId, + senderId: payload.senderId, + senderRole: payload.senderRole, + message: { ...savedMessage }, + }, + 'New message', + HttpStatus.OK, + )); + } + } + } catch (err) { + this.logger.warn(LogCategory.MESSAGE_SEND, 'Fallback emit to expert failed', { roomId }, err as any); + } +} +if (isUserViewer) { + // ✅ When expert sends a message, mark previous messages as seen by expert + // This indicates expert is actively engaging and has seen previous messages + await this.chatService.markMessagesSeen(canonicalSessionId, 'expert'); + + // Fallback delivery: if user socket is not in the room, emit directly to user socket when known + try { + const roomDetails: any = await this.chatService.getRoomDetails(roomId); + const userId = String(roomDetails?.userId || ''); + if (userId) { + const userSocketId = this.chatService.getConnectedSocketId(userId); + if (userSocketId && !members.has(userSocketId)) { + this.server.to(userSocketId).emit('response', createSocketResponse( + 'newMessage', + { + roomId, + sessionId: canonicalSessionId, + senderId: payload.senderId, + senderRole: payload.senderRole, + message: { ...savedMessage }, + }, + 'New message', + HttpStatus.OK, + )); + } + } + } catch (err) { + this.logger.warn(LogCategory.MESSAGE_SEND, 'Fallback emit to user failed', { roomId }, err as any); + } +} + + this.logger.log(LogCategory.MESSAGE_SEND, 'Message sent', { senderId: payload.senderId, senderRole: payload.senderRole, roomId, sessionId: canonicalSessionId, messageLength: payload.message?.length }); + + // Audit log for send message + this.auditLogService.logWebSocketEvent('sendMessage', client, { + userId: payload.senderId, + resource: 'Message', + resourceId: savedMessage?.messageId?.toString() || canonicalSessionId, + newValues: { + sessionId: canonicalSessionId, + roomId, + senderRole: payload.senderRole, + messageLength: payload.message?.length, + }, + statusCode: HttpStatus.OK, + metadata: { + sessionId: canonicalSessionId, + roomId, + senderRole: payload.senderRole, + messageId: savedMessage?.messageId?.toString(), + }, + }).catch(() => {}); // Fire and forget + } catch (err) { + const canonicalSessionId = payload.sessionId || (payload.roomId ? (payload.roomId.startsWith('room:') ? payload.roomId.replace(/^room:/, '') : payload.roomId) : ''); + this.logger.error(LogCategory.ERROR, 'onSendMessage error', err as any, { sessionId: canonicalSessionId, senderId: payload?.senderId }); + + // Audit log for failed send message + this.auditLogService.logWebSocketEvent('sendMessage', client, { + userId: payload?.senderId, + resource: 'Message', + resourceId: canonicalSessionId, + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + metadata: { error: (err as any)?.message, sessionId: canonicalSessionId }, + }).catch(() => {}); // Fire and forget + const message = (err as any)?.message || 'Failed to send message'; + const isReplyError = + typeof message === 'string' && + (message.includes('Reply target') || message.includes('Invalid replyToMessageId')); + const statusCode = message.includes('closed') + ? HttpStatus.CONFLICT + : isReplyError + ? HttpStatus.BAD_REQUEST + : HttpStatus.INTERNAL_SERVER_ERROR; + client.emit('response', createSocketResponse( + 'sendMessage', + {}, + message, + statusCode, + )); + } +} + + // ----- Chat History ----- + @SubscribeMessage('getChatHistory') + async onGetChatHistory( + @MessageBody() payload: { sessionId: string }, + @ConnectedSocket() client: Socket, + ) { + try { + const result = await this.chatService.getChatHistory(payload.sessionId); + // console.log(result) + client.emit('response', createSocketResponse( + 'chatHistory', + { + sessionId: payload.sessionId, + history: result.history, + createdAt: result.createdAt, + onlineStartDate: result.onlineStartDate, + }, + 'Chat history retrieved successfully', + HttpStatus.OK, + )); + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onGetChatHistory error', err as any, { sessionId: payload?.sessionId }); + client.emit('response', createSocketResponse( + 'getChatHistory', + {}, + 'Failed to get chat history', + HttpStatus.INTERNAL_SERVER_ERROR, + )); + } + } + + @SubscribeMessage('getWaitingUsers') +async onGetWaitingUsers(@ConnectedSocket() client: Socket) { + try { + const waiting = await this.chatService.getWaitingUsers(); + + client.emit('response', createSocketResponse( + 'waitingUsers', + { + waiting, + count:waiting.length + }, + `Sent ${waiting.length} waiting users to client`, + HttpStatus.OK, + + )); + this.logger.log(LogCategory.USER_QUEUE, 'Sent waiting users to client', { count: waiting.length }); + } catch (err) { + this.logger.error(LogCategory.ERROR, 'getWaitingUsers error', err as any); + client.emit('response', createSocketResponse( + 'waitingUsers', + {}, + 'Failed to fetch waiting users', + HttpStatus.INTERNAL_SERVER_ERROR, + )); + } +} + + // ----- End Chat ----- + @SubscribeMessage('endChat') + async onEndChat( + @MessageBody() payload: { sessionId: string; userId: string }, + @ConnectedSocket() client: Socket, + ) { + try { + // Check if session is already closed before calling endChat + const session = await this.sessionModel.findById(payload.sessionId).select('onlineChatClosed chatClosed expert userId').lean(); + const wasAlreadyClosed = session && (session.onlineChatClosed || session.chatClosed); + + // Determine who closed the chat: expert, user, or system + let closedByRole: 'expert' | 'user' | 'system' = 'user'; // default to user + + if (session) { + // Get room details to find expertId + try { + const roomId = `room:${payload.sessionId}`; + const roomDetails: any = await this.chatService.getRoomDetails(roomId); + const expertId = roomDetails?.expertId || roomDetails?.expert?._id; + + // Check if payload.userId matches the expert + if (expertId && String(expertId) === String(payload.userId)) { + closedByRole = 'expert'; + } else if (session.userId && String(session.userId) === String(payload.userId)) { + closedByRole = 'user'; + } else { + // If userId doesn't match either, check if it's an expert by querying AdminModel + if (Types.ObjectId.isValid(payload.userId)) { + const admin = await this.adminModel.findById(payload.userId).lean(); + if (admin) { + closedByRole = 'expert'; + } + } + } + } catch (err) { + // If room doesn't exist or error, try to determine from session + if (session.userId && String(session.userId) === String(payload.userId)) { + closedByRole = 'user'; + } else if (Types.ObjectId.isValid(payload.userId)) { + const admin = await this.adminModel.findById(payload.userId).lean(); + if (admin) { + closedByRole = 'expert'; + } + } + } + } + + if (wasAlreadyClosed) { + this.logger.warn(LogCategory.CHAT_END, 'Attempted to close already-closed session', { + sessionId: payload.sessionId, + userId: payload.userId, + onlineChatClosed: session.onlineChatClosed, + chatClosed: session.chatClosed, + assignedExpert: session.expert + }); + + // Still return success but log it as already closed + client.emit('response', createSocketResponse( + 'chatEnded', + { sessionId: payload.sessionId, alreadyClosed: true, role: closedByRole }, + 'Chat session was already closed', + HttpStatus.OK, + )); + + // Audit log with flag indicating it was already closed + this.auditLogService.logWebSocketEvent('endChat', client, { + userId: payload.userId, + resource: 'Chat', + resourceId: payload.sessionId, + statusCode: HttpStatus.OK, + metadata: { + sessionId: payload.sessionId, + alreadyClosed: true, + onlineChatClosed: session.onlineChatClosed, + chatClosed: session.chatClosed, + role: closedByRole, + }, + }).catch(() => {}); // Fire and forget + + return; + } + + const assignments = await this.chatService.endChat(payload.sessionId); + const roomId = `room:${payload.sessionId}`; + + // Acknowledge to the emitter regardless of room membership + client.emit('response', createSocketResponse( + 'chatEnded', + { sessionId: payload.sessionId, role: closedByRole }, + 'Chat session ended', + HttpStatus.OK, + )); + + this.server.to(roomId).emit('response', createSocketResponse( + 'chatEnded', + { sessionId: payload.sessionId, role: closedByRole }, + 'Chat session ended', + HttpStatus.OK, + )); + + client.leave(roomId); + this.logger.log(LogCategory.CHAT_END, 'Chat ended', { sessionId: payload.sessionId, userId: payload.userId }); + + // Audit log for end chat + this.auditLogService.logWebSocketEvent('endChat', client, { + userId: payload.userId, + resource: 'Chat', + resourceId: payload.sessionId, + statusCode: HttpStatus.OK, + metadata: { + sessionId: payload.sessionId, + assignmentsCount: assignments?.length || 0, + role: closedByRole, + }, + }).catch(() => {}); // Fire and forget + + // If any queued users were auto-assigned due to freed capacity, notify both sides now + if (Array.isArray(assignments) && assignments.length) { + for (const a of assignments) { + // notify expert directly by socketId + if (a.socketId) { + this.server.to(a.socketId).emit('response', createSocketResponse( + 'newUserAssigned', + { userId: a.userId, roomId: a.roomId }, + 'New user assigned to expert', + HttpStatus.OK, + )); + } + + // notify user if connected + const userSocketId = this.chatService.getConnectedSocketId(a.userId); + if (userSocketId) { + this.server.to(userSocketId).emit('response', createSocketResponse( + 'expertAssigned', + { expertId: a.expertId, roomId: a.roomId }, + 'Expert assigned successfully', + HttpStatus.OK, + )); + } + + this.logger.log(LogCategory.USER_QUEUE, 'Auto-assigned queued user after endChat', { userId: a.userId, expertId: a.expertId, roomId: a.roomId }); + } + } + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onEndChat error', err as any, { sessionId: payload?.sessionId, userId: payload?.userId }); + client.emit('response', createSocketResponse( + 'endChat', + {}, + 'Failed to end chat', + HttpStatus.INTERNAL_SERVER_ERROR, + )); + } + } + + @SubscribeMessage('getRoomDetails') +async onGetRoomDetails( + @MessageBody() payload: { sessionId: string }, + @ConnectedSocket() client: Socket, +) { + try { + const roomKey = `room:${payload.sessionId}`; + const room = await this.chatService.getRoomDetails(roomKey); + + client.emit( + 'response', + createSocketResponse( + 'roomDetails', + { sessionId: payload.sessionId, room }, + 'Room details retrieved successfully', + HttpStatus.OK, + ), + ); + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onGetRoomDetails error', err as any, { sessionId: payload?.sessionId }); + client.emit( + 'response', + createSocketResponse( + 'getRoomDetails', + {}, + 'Failed to fetch room details', + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + } +} + +@SubscribeMessage('getOnlineExperts') +async onGetOnlineExperts(@ConnectedSocket() client: Socket) { + try { + const experts = await this.chatService.getOnlineExperts(); + + client.emit( + 'response', + createSocketResponse( + 'onlineExperts', + { experts }, + 'Online experts retrieved successfully', + HttpStatus.OK, + ), + ); + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onGetOnlineExperts error', err as any); + client.emit( + 'response', + createSocketResponse( + 'getOnlineExperts', + {}, + 'Failed to fetch online experts', + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + } +} + +@SubscribeMessage('getActiveRooms') +async onGetActiveRooms(@ConnectedSocket() client: Socket) { + try { + const rooms = await this.chatService.getActiveRooms(); + client.emit( + 'response', + createSocketResponse( + 'activeRooms', + { rooms }, + 'Active rooms retrieved successfully', + HttpStatus.OK, + ), + ); + } catch (err) { + this.logger.error(LogCategory.ERROR, 'getActiveRooms error', err as any); + client.emit( + 'response', + createSocketResponse( + 'getActiveRooms', + {}, + 'Failed to fetch active rooms', + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + } +} + +@SubscribeMessage('editMessage') +async onEditMessage( + @MessageBody() + payload: { + roomId: string; + sessionId: string; + messageId: string; + newText: string; + editorId: string; + editorRole: 'user' | 'expert'; + }, + @ConnectedSocket() client: Socket, +) { + try { + this.logger.log(LogCategory.MESSAGE_EDIT, 'Edit message request', { roomId: payload.roomId, sessionId: payload.sessionId, messageId: payload.messageId, editorId: payload.editorId, role: payload.editorRole }); + // 🔒 Only experts can edit messages + if (payload.editorRole !== 'expert') { + return client.emit( + 'response', + createSocketResponse( + 'editMessage', + {}, + 'Only experts can edit messages', + HttpStatus.FORBIDDEN, + ), + ); + } + + // Update in Redis + Mongo + const updatedMessage = await this.chatService.editMessage({ + ...payload, + editorRole: 'expert', + }); + this.logger.log(LogCategory.MESSAGE_EDIT, 'Message updated', { sessionId: payload.sessionId, messageId: payload.messageId, edited: !!(updatedMessage as any)?.edited }); + + // 1) Ack back to the editor socket regardless of room membership + client.emit( + 'response', + createSocketResponse( + 'messageEdited', + { + roomId: payload.roomId, + sessionId: payload.sessionId, + message: updatedMessage, + }, + 'Message edited successfully', + HttpStatus.OK, + ), + ); + + // 2) Notify the other members in the room (exclude sender) + client.to(payload.roomId).emit( + 'response', + createSocketResponse( + 'messageEdited', + { + roomId: payload.roomId, + sessionId: payload.sessionId, + message: updatedMessage, + }, + 'Message edited successfully', + HttpStatus.OK, + ), + ); + + this.logger.log(LogCategory.MESSAGE_EDIT, 'Expert edited message', { expertId: payload.editorId, messageId: payload.messageId, roomId: payload.roomId, sessionId: payload.sessionId }); + + // Audit log for edit message + this.auditLogService.logWebSocketEvent('editMessage', client, { + userId: payload.editorId, + resource: 'Message', + resourceId: payload.messageId, + newValues: { newText: payload.newText, edited: true }, + statusCode: HttpStatus.OK, + metadata: { + sessionId: payload.sessionId, + roomId: payload.roomId, + messageId: payload.messageId, + editorRole: payload.editorRole, + }, + }).catch(() => {}); // Fire and forget + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onEditMessage error', err as any, { sessionId: payload?.sessionId, messageId: payload?.messageId, editorId: payload?.editorId }); + + // Audit log for failed edit message + this.auditLogService.logWebSocketEvent('editMessage', client, { + userId: payload?.editorId, + resource: 'Message', + resourceId: payload?.messageId, + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + metadata: { + error: (err as any)?.message, + sessionId: payload?.sessionId, + messageId: payload?.messageId, + }, + }).catch(() => {}); // Fire and forget + client.emit( + 'response', + createSocketResponse( + 'editMessage', + {}, + 'Failed to edit message', + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + } +} + +@SubscribeMessage('repairRedis') +async onRepairRedis(@ConnectedSocket() client: Socket) { + try { + const result = await this.chatService.repairRedisConsistency({ cleanStale: true }); + client.emit('repairRedis:done', { status: 'ok', result }); + } catch (err) { + client.emit('repairRedis:error', { error: err.message }); + } +} +@SubscribeMessage('getExpertSessions') +async onGetExpertSessions( + @MessageBody() payload: ExpertOnlinePayload, + @ConnectedSocket() client: Socket, +) { + try { + if (!payload?.expertId) { + return client.emit('response', createSocketResponse('getExpertSessions', {}, 'Missing expertId', HttpStatus.BAD_REQUEST)); + } + if (!Types.ObjectId.isValid(payload.expertId) || /@/.test(String(payload.expertId))) { + return client.emit('response', createSocketResponse('getExpertSessions', {}, 'Invalid expertId', HttpStatus.BAD_REQUEST)); + } + + const data = await this.chatService.getExpertActiveSessions(payload.expertId); + + client.emit('response', createSocketResponse( + 'expertSessions', + data, + 'Expert active sessions retrieved', + HttpStatus.OK, + )); + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onGetExpertSessions error', err as any, { expertId: payload?.expertId }); + client.emit('response', createSocketResponse('getExpertSessions', {}, 'Failed to fetch expert sessions', HttpStatus.INTERNAL_SERVER_ERROR)); + } +} + +@SubscribeMessage('markSeen') +async onMarkSeen( + @MessageBody() payload: { sessionId: string; viewerRole: 'user' | 'expert' }, + @ConnectedSocket() client: Socket, +) { + try { + if (!payload?.sessionId || !payload?.viewerRole) { + return client.emit('response', createSocketResponse('markSeen', {}, 'Missing payload', HttpStatus.BAD_REQUEST)); + } + + // Normalize viewerRole to lowercase to handle case-insensitive input + const normalizedViewerRole = payload.viewerRole.toLowerCase() as 'user' | 'expert'; + if (normalizedViewerRole !== 'user' && normalizedViewerRole !== 'expert') { + return client.emit('response', createSocketResponse('markSeen', {}, 'Invalid viewerRole. Must be "user" or "expert"', HttpStatus.BAD_REQUEST)); + } + + const res = await this.chatService.markMessagesSeen(payload.sessionId, normalizedViewerRole); + await this.chatService.cancelPendingExpertTimer(payload.sessionId); + await this.client().set(`session:${payload.sessionId}:expertSeen`, 'true', 'EX', 60 * 60 * 24); + if (normalizedViewerRole === 'expert') { + // bump expert last active where possible; infer from room + try { + const roomId = `room:${payload.sessionId}`; + const room = await this.chatService.getRoomDetails(roomId); + if ((room as any)?.expert?._id || (room as any)?.expertId) { + await this.chatService.refreshExpertLastActive(String((room as any)?.expert?._id || (room as any)?.expertId)); + } + } catch {} + } + // Ack back to the client that invoked markSeen + client.emit('response', createSocketResponse( + 'markSeenAck', + { sessionId: payload.sessionId, updated: res.updated }, + 'Messages marked as seen', + HttpStatus.OK, + )); + + // Notify the other participant in the room (if joined) about updated seen counts + const roomId = `room:${payload.sessionId}`; + this.server.to(roomId).emit('response', createSocketResponse( + 'messagesSeenUpdated', + { sessionId: payload.sessionId, viewerRole: normalizedViewerRole, updated: res.updated }, + 'Seen status updated', + HttpStatus.OK, + )); + + // Audit log for mark seen + this.auditLogService.logWebSocketEvent('markSeen', client, { + resource: 'Message', + resourceId: payload.sessionId, + statusCode: HttpStatus.OK, + metadata: { + sessionId: payload.sessionId, + viewerRole: normalizedViewerRole, + updated: res.updated, + }, + }).catch(() => {}); // Fire and forget + } catch (err) { + this.logger.error(LogCategory.ERROR, 'onMarkSeen error', err as any, { sessionId: payload?.sessionId, viewerRole: payload?.viewerRole }); + + // Audit log for failed mark seen + this.auditLogService.logWebSocketEvent('markSeen', client, { + resource: 'Message', + resourceId: payload?.sessionId, + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + metadata: { + error: (err as any)?.message, + sessionId: payload?.sessionId, + viewerRole: payload?.viewerRole, + }, + }).catch(() => {}); // Fire and forget + client.emit('response', createSocketResponse('markSeen', {}, 'Failed to mark seen', HttpStatus.INTERNAL_SERVER_ERROR)); + } +} + + + + + private async handleTransferResult( + result: TransferChatResult, + requestingClient: Socket, + ): Promise { + if (result.status === 'queued') { + requestingClient.emit( + 'response', + createSocketResponse( + 'transferChat', + { + status: 'queued', + sessionId: result.sessionId, + position: result.position, + preferredExpertId: result.preferredExpertId, + }, + 'Transfer queued until the selected expert has capacity', + HttpStatus.OK, + ), + ); + const userSocketId = this.chatService.getConnectedSocketId(result.userId); + if (userSocketId) { + this.server.to(userSocketId).emit( + 'response', + createSocketResponse( + 'queued', + { + position: result.position, + sessionId: result.sessionId, + waitingForTransfer: true, + preferredExpertId: result.preferredExpertId, + }, + 'Waiting for an expert to take your chat', + HttpStatus.SERVICE_UNAVAILABLE, + ), + ); + } + this.server.to(result.roomId).emit( + 'response', + createSocketResponse( + 'chatTransferred', + { + sessionId: result.sessionId, + status: 'queued', + fromExpertId: result.fromExpertId, + preferredExpertId: result.preferredExpertId, + mode: result.mode, + }, + 'Chat transfer queued', + HttpStatus.OK, + ), + ); + return; + } + + requestingClient.emit( + 'response', + createSocketResponse( + 'transferChat', + { + status: 'transferred', + sessionId: result.sessionId, + toExpertId: result.toExpertId, + }, + 'Chat transferred successfully', + HttpStatus.OK, + ), + ); + + await this.notifyExpertReassigned( + result.sessionId, + result.fromExpertId, + result.toExpertId, + result.userId, + ); + } + + private async notifyExpertReassigned( + sessionId: string, + oldExpertId: string | null, + newExpertId: string | null, + userId?: string, + ): Promise { + const roomId = `room:${sessionId}`; + let expertInfo: { family: string } | null = null; + if (newExpertId && Types.ObjectId.isValid(newExpertId)) { + const admin = await this.adminModel.findById(newExpertId, 'family').lean(); + if (admin) { + expertInfo = { family: (admin as any).family }; + } + } + + this.server.to(roomId).emit( + 'response', + createSocketResponse( + 'chatTransferred', + { + sessionId, + oldExpertId, + newExpertId, + status: 'transferred', + ...(expertInfo ? { expert: expertInfo } : {}), + }, + 'Chat has been transferred to another expert', + HttpStatus.OK, + ), + ); + + if (userId) { + const userSocketId = this.chatService.getConnectedSocketId(userId); + if (userSocketId) { + this.server.sockets.sockets.get(userSocketId)?.join(roomId); + } + } + + if (newExpertId) { + const socketId = + (await this.chatService.getExpertSocketIdFromRedis(newExpertId)) || + (await this.chatService.getOnlineExperts()).find( + (e: any) => e.expertId === newExpertId, + )?.socketId; + if (socketId) { + this.server.sockets.sockets.get(socketId)?.join(roomId); + this.server.to(socketId).emit( + 'response', + createSocketResponse( + 'newUserAssigned', + { userId, roomId, sessionId, expertId: newExpertId, fromTransfer: true }, + 'Chat transferred to you', + HttpStatus.OK, + ), + ); + } + } + + if (oldExpertId) { + const oldSocketId = await this.chatService.getExpertSocketIdFromRedis(oldExpertId); + if (oldSocketId) { + this.server.to(oldSocketId).emit( + 'response', + createSocketResponse( + 'sessionTransferredAway', + { sessionId, roomId }, + 'This chat was transferred to another expert', + HttpStatus.OK, + ), + ); + } + } + } + + // ----- Generic Notify ----- + notifyClient(clientId: string, payload: NotificationPayload) { + this.server.to(clientId).emit('response', createSocketResponse( + 'notify', + payload, + 'Notification received', + HttpStatus.OK, + )); + } + + broadcastError(client: Socket, message: string) { + client.emit('response', createSocketResponse( + 'error', + { code: 'GENERAL_ERROR' }, + message, + HttpStatus.INTERNAL_SERVER_ERROR, + )); + } +} \ No newline at end of file diff --git a/src/socket/support-management/chat.service.ts b/src/socket/support-management/chat.service.ts new file mode 100644 index 0000000..d5b86aa --- /dev/null +++ b/src/socket/support-management/chat.service.ts @@ -0,0 +1,4218 @@ +import { Injectable, Logger, ForbiddenException, BadRequestException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { Socket } from 'socket.io'; +import { v4 as uuidv4 } from 'uuid'; +import { + JoinRoomPayload, + MessageReplyToSnapshot, + RequestExpertPayload, + SendMessagePayload, + UserRole, +} from './dto/eventPayloads.dto'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { AdminModel } from 'src/database/model/admin.model'; +import { UserModel } from 'src/database/model/user.model'; +import { ReassignedLogsModel } from 'src/database/model/reassigned-logs.model'; +import { ChatMessageAttachmentModel } from 'src/database/model/chat-message-attachment.model'; +import { RedisService } from "src/common/helpers/redis.service"; +import { TimeHelper } from "src/common/tools/time-helper"; + + + +interface MessageRedis { + messageId: string; // will be ObjectId string + message: string; + sender: UserRole; + react?: string; + createdAt: [string, string]; + createdISO: string; + edited?: boolean; + seenBy?: { user?: boolean; expert?: boolean }; + replyTo?: MessageReplyToSnapshot; + messageType?: 'text' | 'image' | 'voice' | 'document'; + voiceDurationSec?: number; + voiceMimeType?: string; + /** Non-voice attachments (image/document). Voice continues to use voiceMimeType for backward compatibility. */ + mimeType?: string; +} + +interface RoomRedisData { + roomId: string; + sessionId: string; + userId: string; + expertId: string | null; + createdAt: [string, string]; + messages: MessageRedis[]; + isActive: boolean; +} + +export interface TransferCandidateDto { + expertId: string; + name: string; + family: string; + capacityLeft: number; + activeSessions: number; + maxSessions: number; +} + +export type TransferChatResult = + | { + status: 'transferred'; + sessionId: string; + roomId: string; + userId: string; + fromExpertId: string; + toExpertId: string; + toExpertSocketId: string | null; + mode: 'selective' | 'random'; + reason?: string; + } + | { + status: 'queued'; + sessionId: string; + roomId: string; + userId: string; + fromExpertId: string; + preferredExpertId: string; + position: number; + mode: 'selective' | 'random'; + reason?: string; + }; + +interface WaitingQueueEntry { + userId: string; + sessionId: string; + queuedAt: string; + preferredExpertId?: string; + source?: 'transfer' | 'request'; +} + +interface ExpertRedisData { + expertId: string; + socketId: string; + activeSessions: number; + maxSessions: number; + sessionIds: string[]; // current session ids assigned to this expert + isOnline:boolean; +} + +@Injectable() +export class ChatService { + private readonly logger = new Logger(ChatService.name); + private readonly ONLINE_EXPERTS_KEY = 'onlineExperts'; // set of expertIds + private readonly EXPERT_KEY_PREFIX = 'expert:'; // full key = expert: + private readonly WAITING_QUEUE_KEY = 'waitingQueue'; // list of userIds + private readonly ROOMS_HASH_KEY = 'rooms:active'; // hash: roomId -> JSON(RoomRedisData) + private expertSockets = new Map(); // expertId → socketId + private readonly MAX_SESSIONS_PER_EXPERT = 4; + private WAITING_USERS_KEY = 'waiting_users'; + // In-memory mapping (optional fast lookup). We still use Redis as source of truth. + private connectedClients = new Map(); // userId -> socketId (keeps track of connected sockets in this instance) + private readonly PENDING_PREFIX = 'pendingExpert:'; + private readonly INACTIVE_PREFIX = 'inactiveChat:'; + + // Feature flag: if true and environment supports it, we could use RedisJSON arrays. We still default to Redis Sets to avoid hash overwrites + private readonly USE_REDIS_JSON_SESSIONS = String(process.env.USE_REDIS_JSON_SESSIONS || '').toLowerCase() === 'true'; + private readonly REPLY_PREVIEW_MAX_LEN = 200; + + private normalizeCreatedISO(value: any): string { + if (!value) return new Date().toISOString(); + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'string') { + const d = new Date(value); + return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); + } + try { + return new Date(value).toISOString(); + } catch { + return new Date().toISOString(); + } + } + + private normalizeReplyToFromDoc(raw: any): MessageReplyToSnapshot | undefined { + if (!raw || !raw.messageId) return undefined; + return { + messageId: String(raw.messageId?.toString?.() ?? raw.messageId), + textPreview: raw.textPreview ?? '', + sender: raw.sender, + createdISO: raw.createdISO != null ? this.normalizeCreatedISO(raw.createdISO) : undefined, + unavailable: !!raw.unavailable, + }; + } + + private findMessageInSessionList(list: any[] | undefined, messageId: string) { + if (!list?.length) return null; + for (const m of list) { + const mid = String(m.messageId?.toString?.() ?? m.messageId); + if (mid !== messageId) continue; + const text: string = m.message ?? m.text ?? ''; + return { text, sender: m.sender as UserRole, createdISO: this.normalizeCreatedISO(m.createdISO) }; + } + return null; + } + + private buildReplyToSnapshot( + replyToMessageId: string, + room: RoomRedisData, + statusDoc: any, + ): MessageReplyToSnapshot { + const fromRoom = this.findMessageInSessionList(room?.messages, replyToMessageId); + const fromMongo = !fromRoom ? this.findMessageInSessionList(statusDoc?.messages, replyToMessageId) : null; + const found = fromRoom || fromMongo; + if (!found) { + throw new Error('Reply target message not found in this session'); + } + const { text, sender, createdISO } = found; + const previewBase = + typeof text === 'string' && + (text.includes('/uploads/voices/') || + text.includes('/voices/') || + text.includes('/chats/')) + ? '[Voice message]' + : text; + const textPreview = + previewBase.length > this.REPLY_PREVIEW_MAX_LEN + ? `${previewBase.slice(0, this.REPLY_PREVIEW_MAX_LEN)}…` + : previewBase; + return { + messageId: replyToMessageId, + textPreview, + sender, + createdISO, + unavailable: false, + }; + } + + private enrichReplyToAvailability(messages: T[]): T[] { + const ids = new Set(messages.map((m) => String(m.messageId))); + return messages.map((m) => { + if (!m.replyTo?.messageId) return m; + const refId = String(m.replyTo.messageId); + if (ids.has(refId)) { + if (m.replyTo.unavailable) { + return { ...m, replyTo: { ...m.replyTo, unavailable: false } } as T; + } + return m; + } + return { ...m, replyTo: { ...m.replyTo, unavailable: true, messageId: refId } } as T; + }); + } + + private expertSessionsKey(expertId: string) { + return `${this.EXPERT_KEY_PREFIX}${expertId}:sessions`; + } + + private async getExpertSessionIdsFromSet(expertId: string): Promise { + const client = this.client(); + const setKey = this.expertSessionsKey(expertId); + const members = await client.smembers(setKey); + return Array.isArray(members) ? [...new Set(members.filter(Boolean))] : []; + } + + /** + * Filters out closed sessions from a list of session IDs by checking MongoDB + * This ensures we don't sync stale/closed sessions into onlineExperts + */ + private async filterActiveSessions(sessionIds: string[]): Promise { + if (sessionIds.length === 0) return []; + + try { + // Batch check all sessions in one query + const sessions = await this.sessionModel.find({ + _id: { $in: sessionIds.map(id => new Types.ObjectId(id)) }, + }).select('_id onlineChatClosed chatClosed').lean(); + + const closedSessionIds = new Set( + sessions + .filter(s => (s as any).onlineChatClosed || (s as any).chatClosed) + .map(s => String(s._id)) + ); + + const activeSessionIds = sessionIds.filter(id => !closedSessionIds.has(id)); + + // If we found closed sessions, log them for debugging + if (closedSessionIds.size > 0) { + this.logger.warn( + `[filterActiveSessions] Filtered out ${closedSessionIds.size} closed session(s): ${Array.from(closedSessionIds).join(', ')}` + ); + } + + return activeSessionIds; + } catch (err) { + this.logger.error('[filterActiveSessions] Error filtering sessions', err as any); + // On error, return all (conservative - don't drop valid sessions) + return sessionIds; + } + } + + private async addSessionToExpertSet(expertId: string, sessionId: string): Promise { + const client = this.client(); + const setKey = this.expertSessionsKey(expertId); + this.logger.log(`[SETS] SADD ${setKey} ${sessionId}`); + await client.sadd(setKey, sessionId); + const count = await client.scard(setKey); + this.logger.log(`[SETS] SCARD ${setKey} => ${count}`); + return count; + } + + private async removeSessionFromExpertSet(expertId: string, sessionId: string): Promise { + const client = this.client(); + const setKey = this.expertSessionsKey(expertId); + this.logger.log(`[SETS] SREM ${setKey} ${sessionId}`); + await client.srem(setKey, sessionId); + const count = await client.scard(setKey); + this.logger.log(`[SETS] SCARD ${setKey} => ${count}`); + return count; + } + + private async updateOnlineExpertHash(expertId: string, patch: Partial & { includeSessionIdsFromSet?: boolean }) { + const client = this.client(); + const before = await client.hget(this.ONLINE_EXPERTS_KEY, expertId); + this.logger.log(`[HSET][BEFORE] onlineExperts[${expertId}] = ${before}`); + + // Merge patch onto existing (best-effort) + let next: any = {}; + try { + next = before ? JSON.parse(before) : {}; + } catch { + next = {}; + } + + // Optionally refresh sessionIds from set to avoid drift + if (patch.includeSessionIdsFromSet) { + const sessionIds = await this.getExpertSessionIdsFromSet(expertId); + // CRITICAL: Filter out closed sessions before syncing to hash + // This prevents stale/closed sessions from being added back to onlineExperts + const activeSessionIds = await this.filterActiveSessions(sessionIds); + next.sessionIds = activeSessionIds; + next.activeSessions = activeSessionIds.length; + + // If we filtered out closed sessions, remove them from the SET as well + if (activeSessionIds.length < sessionIds.length) { + const closedSessionIds = sessionIds.filter(id => !activeSessionIds.includes(id)); + for (const closedSessionId of closedSessionIds) { + try { + await this.removeSessionFromExpertSet(expertId, closedSessionId); + this.logger.log(`[updateOnlineExpertHash] Removed closed session ${closedSessionId} from expert ${expertId} SET`); + } catch (err) { + this.logger.warn(`[updateOnlineExpertHash] Failed to remove closed session ${closedSessionId} from SET`, err as any); + } + } + } + } + + // Apply other fields from patch + Object.assign(next, patch); + delete next.includeSessionIdsFromSet; + + await client.hset(this.ONLINE_EXPERTS_KEY, expertId, JSON.stringify(next)); + const after = await client.hget(this.ONLINE_EXPERTS_KEY, expertId); + this.logger.log(`[HSET][AFTER] onlineExperts[${expertId}] = ${after}`); + } + + // ============================= + // ===== VALIDATION HELPERS ==== + // ============================= + + /** + * Validates userId exists in User collection + */ + private async validateUserId(userId: string): Promise { + if (!userId || !Types.ObjectId.isValid(userId)) { + this.logger.warn(`Invalid userId format: ${userId}`); + return false; + } + + try { + const userExists = await this.userModel.exists({ _id: userId }); + if (!userExists) { + this.logger.warn(`UserId does not exist in User collection: ${userId}`); + return false; + } + return true; + } catch (err) { + this.logger.error(`Error validating userId ${userId}:`, err); + return false; + } + } + + /** + * Validates sessionId exists and optionally checks if userId matches + */ + private async validateSessionId(sessionId: string, expectedUserId?: string): Promise<{ valid: boolean; session?: any }> { + if (!sessionId || !Types.ObjectId.isValid(sessionId)) { + this.logger.warn(`Invalid sessionId format: ${sessionId}`); + return { valid: false }; + } + + try { + const session = await this.sessionModel.findById(sessionId).lean(); + if (!session) { + this.logger.warn(`SessionId does not exist in Session collection: ${sessionId}`); + return { valid: false }; + } + + // If expectedUserId is provided, verify it matches + if (expectedUserId) { + const sessionUserId = session?.userId?.toString?.(); + if (sessionUserId !== expectedUserId) { + this.logger.warn(`Session ${sessionId} userId mismatch: expected ${expectedUserId}, got ${sessionUserId}`); + return { valid: false }; + } + } + + return { valid: true, session }; + } catch (err) { + this.logger.error(`Error validating sessionId ${sessionId}:`, err); + return { valid: false }; + } + } + + /** + * Validates and normalizes expertId from various formats (ObjectId, username, email) + */ + private async normalizeExpertId(rawExpert: any, sessionId: string): Promise { + if (!rawExpert) { + return null; + } + + const expertStr = String(rawExpert); + + // Case 1: Already a valid ObjectId + if (Types.ObjectId.isValid(expertStr)) { + try { + const expertExists = await this.adminModel.exists({ _id: expertStr }); + if (expertExists) { + return expertStr; + } else { + this.logger.warn(`Session ${sessionId} references non-existent expert ObjectId: ${expertStr}`); + return null; + } + } catch (err) { + this.logger.error(`Error validating expert ObjectId ${expertStr} for session ${sessionId}:`, err); + return null; + } + } + + // Case 2: Username or email - look it up + if (typeof expertStr === 'string' && expertStr.length > 0) { + try { + const admin = await this.adminModel + .findOne({ $or: [{ username: expertStr }, { email: expertStr }] }) + .select('_id') + .lean(); + + if (admin?._id) { + const normalizedId = String((admin as any)._id); + this.logger.log(`Normalized expertId for session ${sessionId}: ${expertStr} -> ${normalizedId}`); + return normalizedId; + } else { + this.logger.warn(`Session ${sessionId} references non-existent expert: ${expertStr}`); + return null; + } + } catch (err) { + this.logger.error(`Error normalizing expertId for session ${sessionId}:`, err); + return null; + } + } + + return null; + } + + /** + * Validates userId from session and returns it if valid + */ + private async validateAndGetUserId(session: any, sessionId: string): Promise { + if (!session?.userId) { + this.logger.warn(`Session ${sessionId} has no userId`); + return null; + } + + const userId = session.userId?.toString?.(); + if (!userId || !Types.ObjectId.isValid(userId)) { + this.logger.warn(`Session ${sessionId} has invalid userId: ${userId}`); + return null; + } + + // Verify user exists in database + const isValid = await this.validateUserId(userId); + if (!isValid) { + this.logger.warn(`Session ${sessionId} references non-existent user: ${userId}`); + return null; + } + + return userId; + } + + // Ensure canonical mapping for expert/session exists and sync the hash entry + async ensureExpertSessionMapping(expertId: string, sessionId: string) { + try { + await this.addSessionToExpertSet(expertId, sessionId); + await this.updateOnlineExpertHash(expertId, { includeSessionIdsFromSet: true, isOnline: true }); + } catch (err) { + this.logger.warn('ensureExpertSessionMapping failed', err as any); + } + } + + /** + * Logs a reassignment event to the reassigned_logs collection + * @param sessionId - The session ID being reassigned + * @param userId - The user ID who owns the session + * @param fromExpertEmail - The email of the expert the session is being reassigned from + * @param toExpertEmail - The email of the expert the session is being reassigned to, or null if no expert available + */ + private async logReassignment( + sessionId: string, + userId: string, + fromExpertEmail: string, + toExpertEmail: string | null, + options?: { + source?: 'manual' | 'auto'; + mode?: 'selective' | 'random'; + reason?: string; + }, + ): Promise { + try { + await this.reassignedLogsModel.create({ + sessionId: new Types.ObjectId(sessionId), + userId: new Types.ObjectId(userId), + from: fromExpertEmail, + to: toExpertEmail, + source: options?.source ?? 'auto', + mode: options?.mode, + reason: options?.reason, + }); + this.logger.log( + `[logReassignment] Logged reassignment for session ${sessionId}: ${fromExpertEmail} -> ${toExpertEmail || 'null'}`, + ); + } catch (err) { + this.logger.error(`[logReassignment] Failed to log reassignment for session ${sessionId}`, err as any); + } + } + + constructor( + @InjectModel(AdminModel.name) + private readonly adminModel: Model, + @InjectModel(SessionModel.name) + private readonly sessionModel: Model, + @InjectModel(UserModel.name) + private readonly userModel: Model, + @InjectModel(ReassignedLogsModel.name) + private readonly reassignedLogsModel: Model, + @InjectModel(ChatMessageAttachmentModel.name) + private readonly chatAttachmentModel: Model, + private readonly redisService: RedisService, + ) {} + private client() { + return this.redisService.getClient(); + } + + + // ============================= + // ===== CONNECTION LOGIC ====== + // ============================= + + async registerClient(payload: JoinRoomPayload, socketId: string) { + try { + if (!payload?.userId) return; + this.connectedClients.set(payload.userId, socketId); + this.logger.log(`registerClient: ${payload.userId} -> ${socketId}`); + } catch (err) { + this.logger.error('registerClient error', err as any); + } + } + + async unregisterClient(userId: string) { + try { + this.connectedClients.delete(userId); + // Also remove from waiting queue if present + await this.removeUserFromQueue(userId); + this.logger.log(`unregisterClient: ${userId} removed`); + } catch (err) { + this.logger.error('unregisterClient error', err as any); + } + } + + // Expose connected user's socket id for server-driven notifications/room joins + getConnectedSocketId(userId: string): string | null { + try { + return this.connectedClients.get(userId) || null; + } catch { + return null; + } + } + + // Add a user to the waiting queue +async addWaitingUser(userId: string, sessionId: string) { + const client = this.client(); + const userData = JSON.stringify({ + userId, + sessionId, + timestamp: Date.now(), + }); + await client.hset(this.WAITING_USERS_KEY, sessionId, userData); + return true; +} + +// Remove a user from the waiting queue +async removeWaitingUser(sessionId: string) { + const client = this.client(); + await client.hdel(this.WAITING_USERS_KEY, sessionId); +} + + + async handleDisconnect(client: Socket) { + // When a socket disconnects, find who it belonged to + const userId = [...this.connectedClients.entries()].find( + ([, socket]) => socket === client.id, + )?.[0]; + + if (!userId) return; + + this.logger.warn(`Socket disconnected: ${client.id} (${userId})`); + this.connectedClients.delete(userId); + + // 1. Check if user is in waiting queue BEFORE removing (to identify queued sessions) + const redisClient = this.client(); + let queuedSessionIds: string[] = []; + try { + const queue = await redisClient.lrange(this.WAITING_QUEUE_KEY, 0, -1); + queuedSessionIds = queue + .map((entry) => { + try { + const parsed = JSON.parse(entry); + if (parsed.userId === userId && parsed.sessionId) { + return parsed.sessionId; + } + } catch { + // Skip invalid entries + } + return null; + }) + .filter((id): id is string => id !== null); + } catch (err) { + this.logger.error(`[handleDisconnect] Failed to check waiting queue`, err as any); + } + + // 2. Auto-close sessions if user was queued (not yet assigned to expert) + if (queuedSessionIds.length > 0) { + try { + for (const sessionId of queuedSessionIds) { + // Verify session is not yet connected to expert before closing + try { + const session = await this.sessionModel.findById(sessionId).select('connectedToExpert onlineChatClosed userId').lean(); + if (session && String(session.userId) === userId && !session.connectedToExpert && !session.onlineChatClosed) { + // User was queued and disconnected - close the session + this.logger.log(`[handleDisconnect] Auto-closing queued session ${sessionId} for disconnected user ${userId}`); + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $set: { + onlineChatClosed: true, + onlineEndDate: new Date(), + chatClosed: true, + }, + }, + ); + // Also remove from waiting_users hash if present + await redisClient.hdel(this.WAITING_USERS_KEY, sessionId); + this.logger.log(`[handleDisconnect] Closed queued session ${sessionId} for disconnected user`); + } + } catch (err) { + this.logger.error(`[handleDisconnect] Failed to close queued session ${sessionId}`, err as any); + } + } + } catch (err) { + this.logger.error(`[handleDisconnect] Failed to auto-close queued sessions`, err as any); + } + } + + // 3. Clean up waiting queue - remove user from queue + try { + const removed = await this.removeUserFromQueue(userId); + if (removed > 0) { + this.logger.log(`[handleDisconnect] Removed user ${userId} from waiting queue (${removed} entries)`); + } + } catch (err) { + this.logger.error(`[handleDisconnect] Failed to remove user from queue`, err as any); + } + + // Also check if expert disconnected + const expertId = [...this.expertSockets.entries()].find( + ([, socket]) => socket === client.id, + )?.[0]; + + if (expertId) { + this.expertSockets.delete(expertId); + try { + const r = this.client(); + await r.set( + `expert:${expertId}:disconnectedAt`, + Date.now().toString(), + 'EX', + 60 * 60 * 24, + ); + this.logger.warn( + `Expert socket disconnected (grace period before auto-reassign): ${expertId}`, + ); + } catch (err) { + this.logger.error('Failed to set expert disconnectedAt', err as any); + } + } + } + + // ============================= + // ===== EXPERT AVAILABILITY === + // ============================= + + async setExpertOnline( + expertId: string, + socketId: string, + ): Promise< + Array<{ + userId: string; + sessionId: string; + roomId: string; + expertId: string; + socketId: string; + isTransfer?: boolean; + }> + > { + try { + const client = this.client(); + + // Use includeSessionIdsFromSet to automatically filter closed sessions + // This ensures we don't sync stale/closed sessions when expert comes online + this.logger.log(`[setExpertOnline] Writing onlineExperts[${expertId}] with filtered sessions from SET`); + await this.updateOnlineExpertHash(expertId, { + expertId, + socketId, + maxSessions: this.MAX_SESSIONS_PER_EXPERT, + includeSessionIdsFromSet: true, // This will filter closed sessions automatically + isOnline: true, + }); + + this.expertSockets.set(expertId, socketId); + await client.del(`expert:${expertId}:disconnectedAt`); + + this.logger.log(`setExpertOnline: ${expertId} (socket ${socketId})`); + + // After expert comes online, try to assign queued users (if any) + const assignments = await this.assignFromQueue(); + // Touch expert last active + try { + await this.refreshExpertLastActive(expertId); + } catch {} + return assignments; + } catch (err) { + this.logger.error('setExpertOnline error', err as any); + throw err; + } + } + + async setExpertOffline(expertId: string) { + try { + const client = this.client(); + + const sessionIds = await this.getExpertSessionIdsFromSet(expertId); + const activeSessionIds = await this.filterActiveSessions(sessionIds); + if (activeSessionIds.length > 0) { + throw new ForbiddenException( + 'Cannot go offline while you have active chat sessions. Transfer or end them first.', + ); + } + + await client.del(`expert:${expertId}:disconnectedAt`); + + // remove from online set / hash + await client.hdel(this.ONLINE_EXPERTS_KEY, expertId); + + this.expertSockets.delete(expertId); + this.logger.log(`setExpertOffline: ${expertId}`); + } catch (err) { + this.logger.error('setExpertOffline error', err as any); + throw err; + } + } + + getExpertSocketIdFromRedis(expertId: string): Promise { + return this.client() + .hget(this.ONLINE_EXPERTS_KEY, expertId) + .then((raw) => { + if (!raw) return null; + try { + return (JSON.parse(raw) as ExpertRedisData).socketId || null; + } catch { + return null; + } + }) + .catch(() => null); + } + + async getOnlineExperts() { + const client = this.client(); + try { + const entries = await client.hgetall(this.ONLINE_EXPERTS_KEY); + + if (Object.keys(entries).length === 0) { + return []; + } + + const experts: ExpertRedisData[] = Object.values(entries).map((v) => { + try { + return JSON.parse(v as string) as ExpertRedisData; + } catch { + return null as any; + } + }).filter(Boolean); + + // Filter for experts who are explicitly marked as online + const onlineExperts = experts.filter((e) => e.isOnline); + this.logger.log(`Found ${onlineExperts.length} online experts after filtering.`); + + // Fetch additional details from adminModel for each online expert + const enrichedExperts = await Promise.all( + onlineExperts.map(async (expertData) => { + this.logger.log(`Fetching admin details for expertId: ${expertData.expertId}`); + const admin = await this.adminModel + .findById(expertData.expertId) + .select('_id name family role') + .lean(); + + return { + ...expertData, + name: admin?.name, + family: admin?.family, + role: admin?.role, + }; + }), + ); + + return enrichedExperts; + } catch (err) { + this.logger.error('getOnlineExperts error', err as any); + return []; + } + } + + async getActiveRooms() { + const client = this.client(); + const keys = await client.hkeys(this.ROOMS_HASH_KEY); + const all = await Promise.all( + keys.map(async (key) => { + const data = await client.hget(this.ROOMS_HASH_KEY, key); + try { + return JSON.parse(data); + } catch { + return null; + } + }), + ); + return all + .filter((r) => r && r.isActive) + .map((r) => ({ + ...r, + // normalize unknown userId if available from Mongo + userId: r.userId && r.userId !== 'unknown-user' ? r.userId : r.userId, + })); + } + + async getUserActiveSessions(userId: string): Promise> { + const client = this.client(); + const roomsRaw = await client.hgetall(this.ROOMS_HASH_KEY); + if (!roomsRaw || Object.keys(roomsRaw).length === 0) return []; + + const sessions: Array<{ sessionId: string; roomId: string; expertId: string }> = []; + + for (const [roomId, raw] of Object.entries(roomsRaw)) { + try { + const room = JSON.parse(raw as string) as RoomRedisData; + // Check strict equality for userId + // Note: we accept any active state (undefined or true), only explicit false is excluded + if (room?.userId === userId && (room as any)?.isActive !== false) { + sessions.push({ sessionId: String(room.sessionId), roomId, expertId: String(room.expertId) }); + } + } catch {} + } + return sessions; + } + + // ======================== + // User Queue Management + // ======================== + + async enqueueUser( + userId: string, + sessionId: string, + options?: { preferredExpertId?: string; source?: 'transfer' | 'request' }, + ) { + try { + const client = this.client(); + + // Use Redis transaction to atomically check and add + // This prevents race conditions where multiple requests check simultaneously + const raw = await client.lrange(this.WAITING_QUEUE_KEY, 0, -1); + const userExists = raw.some((entry) => { + try { + const parsed = JSON.parse(entry) as WaitingQueueEntry; + return parsed.userId === userId; + } catch { + return false; + } + }); + + if (!userExists) { + const queuedData: WaitingQueueEntry = { + userId, + sessionId, + queuedAt: new Date().toISOString(), + ...(options?.preferredExpertId + ? { preferredExpertId: options.preferredExpertId } + : {}), + ...(options?.source ? { source: options.source } : {}), + }; + // Use Redis transaction (MULTI/EXEC) for atomic operation + const multi = client.multi(); + multi.lrange(this.WAITING_QUEUE_KEY, 0, -1); + multi.rpush(this.WAITING_QUEUE_KEY, JSON.stringify(queuedData)); + const results = await multi.exec(); + + // Double-check after transaction to ensure no duplicate was added + const afterRaw = await client.lrange(this.WAITING_QUEUE_KEY, 0, -1); + const duplicates = afterRaw.filter((entry) => { + try { + const parsed = JSON.parse(entry); + return parsed.userId === userId; + } catch { + return false; + } + }); + + if (duplicates.length > 1) { + // Remove duplicates, keep only the first one + this.logger.warn(`Found ${duplicates.length} duplicate entries for userId ${userId}, removing duplicates`); + for (let i = 1; i < duplicates.length; i++) { + await client.lrem(this.WAITING_QUEUE_KEY, 1, duplicates[i]); + } + } + + this.logger.log(`enqueueUser: ${userId} with sessionId ${sessionId} queued at ${queuedData.queuedAt}`); + } else { + this.logger.log(`enqueueUser: ${userId} already queued`); + } + } catch (err) { + this.logger.error('enqueueUser error', err as any); + throw err; + } + } + + /** + * Removes all entries for a userId from the waiting queue + */ + async removeUserFromQueue(userId: string): Promise { + try { + const client = this.client(); + const raw = await client.lrange(this.WAITING_QUEUE_KEY, 0, -1); + let removed = 0; + + // Remove all entries matching userId + for (const entry of raw) { + try { + const parsed = JSON.parse(entry); + if (parsed.userId === userId) { + await client.lrem(this.WAITING_QUEUE_KEY, 1, entry); + removed++; + } + } catch { + // Skip invalid entries + } + } + + if (removed > 0) { + this.logger.log(`removeUserFromQueue: removed ${removed} entries for ${userId}`); + } + return removed; + } catch (err) { + this.logger.error('removeUserFromQueue error', err as any); + return 0; + } + } + + async dequeueUser(): Promise { + try { + const client = this.client(); + const val = await client.lpop(this.WAITING_QUEUE_KEY); + return val; + } catch (err) { + this.logger.error('dequeueUser error', err as any); + return null; + } + } + + // ============================= + // ===== MATCHING USERS ======== + // ============================= + + async assignExpert( + payload: RequestExpertPayload, + isBusinessHours: boolean = false, + options?: { + preferredExpertId?: string; + excludeExpertId?: string; + }, + ): Promise< + | { + status: 'assigned'; + sessionId: string; + roomId: string; + expertId: string; + socketId: string; + } + | { status: 'queued' } + | { status: 'no_experts_available' } + > { + const { userId, sessionId } = payload; + + try { + this.logger.log(`[assignExpert] Using PENDING_EXPERT_SECONDS=${process.env.PENDING_EXPERT_SECONDS || '300'}`); + + // Validate userId exists + const isUserIdValid = await this.validateUserId(userId); + if (!isUserIdValid) { + throw new Error(`Invalid userId: ${userId} does not exist`); + } + + // Validate sessionId exists and userId matches + const sessionValidation = await this.validateSessionId(sessionId, userId); + if (!sessionValidation.valid) { + throw new Error(`Invalid sessionId: ${sessionId} does not exist or userId mismatch`); + } + + const client = this.client(); + + // fetch all online experts + const entries = await client.hgetall(this.ONLINE_EXPERTS_KEY); + if (Object.keys(entries).length === 0) { + // During business hours, if no experts are online, return special status + // Outside business hours, this case shouldn't happen (gateway handles it) + if (isBusinessHours) { + this.logger.warn('assignExpert: no online experts during business hours'); + return { status: 'no_experts_available' }; + } + // Fallback: queue if not in business hours (shouldn't reach here normally) + await this.enqueueUser(userId, sessionId); + this.logger.warn('assignExpert: no online experts, user queued'); + return { status: 'queued' }; + } + + // parse all experts safely + const experts: ExpertRedisData[] = Object.values(entries) + .map((v) => { + try { + return JSON.parse(v as string) as ExpertRedisData; + } catch { + return null as any; + } + }) + .filter(Boolean); + + // filter available ones + let available = experts.filter( + (e) => e.isOnline && (e.activeSessions || 0) < (e.maxSessions || this.MAX_SESSIONS_PER_EXPERT), + ); + + if (options?.excludeExpertId) { + available = available.filter((e) => e.expertId !== options.excludeExpertId); + } + + if (options?.preferredExpertId) { + const preferred = available.find((e) => e.expertId === options.preferredExpertId); + if (!preferred) { + this.logger.warn( + `assignExpert: preferred expert ${options.preferredExpertId} unavailable, user queued`, + ); + await this.enqueueUser(userId, sessionId, { + preferredExpertId: options.preferredExpertId, + source: 'transfer', + }); + return { status: 'queued' }; + } + available = [preferred]; + } + + if (!available.length) { + await this.enqueueUser(userId, sessionId); + this.logger.warn('assignExpert: no experts with capacity, user queued'); + return { status: 'queued' }; + } + + // choose least busy + available.sort((a, b) => (a.activeSessions || 0) - (b.activeSessions || 0)); + const chosen = available[0]; + + // CRITICAL: Remove session from all expert SETs first to prevent duplicates + // This handles cases where the session might already be assigned to another expert + const expertsToSync: string[] = []; + for (const expert of experts) { + if (expert.expertId !== chosen.expertId) { + try { + const setKey = this.expertSessionsKey(expert.expertId); + const isMember = await client.sismember(setKey, sessionId); + if (isMember) { + await client.srem(setKey, sessionId); + expertsToSync.push(expert.expertId); + this.logger.log(`[assignExpert] Removed session ${sessionId} from expert ${expert.expertId} SET before reassignment`); + } + } catch (err) { + this.logger.warn(`[assignExpert] Failed to check/remove session ${sessionId} from expert ${expert.expertId} SET`, err as any); + } + } + } + + // Update canonical session set for chosen expert + const newCount = await this.addSessionToExpertSet(chosen.expertId, sessionId); + + // Sync the hash with sessions from set and chosen socketId + this.logger.log(`[assignExpert] Sync hash with set for expert ${chosen.expertId}, expected count: ${newCount}`); + await this.updateOnlineExpertHash(chosen.expertId, { + includeSessionIdsFromSet: true, + socketId: chosen.socketId, + expertId: chosen.expertId, + maxSessions: chosen.maxSessions, + isOnline: true, + }); + + // Sync all experts that had the session removed + for (const expertId of expertsToSync) { + try { + await this.updateOnlineExpertHash(expertId, { includeSessionIdsFromSet: true, isOnline: true }); + } catch (err) { + this.logger.warn(`[assignExpert] Failed to sync expert ${expertId} hash after session removal`, err as any); + } + } + + // Check if room already exists, if so update it, otherwise create new + const roomId = `room:${sessionId}`; + const existingRoomRaw = await client.hget(this.ROOMS_HASH_KEY, roomId); + + let roomData: RoomRedisData; + if (existingRoomRaw) { + // Room exists - update expertId and preserve existing data + try { + roomData = JSON.parse(existingRoomRaw) as RoomRedisData; + const oldExpertId = roomData.expertId; + roomData.expertId = chosen.expertId; + roomData.isActive = true; + // Preserve existing messages, createdAt, etc. + this.logger.log(`[assignExpert] Updating existing room ${roomId}: expertId changed from ${oldExpertId} to ${chosen.expertId}`); + } catch (err) { + // Corrupted room data - create new room + this.logger.warn(`[assignExpert] Corrupted room data for ${roomId}, creating new room`, err as any); + roomData = { + roomId, + sessionId, + userId, + expertId: chosen.expertId, + createdAt: [ + new Date().toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + new Intl.DateTimeFormat('fa-IR-u-ca-persian').format(new Date()), + ], + messages: [], + isActive: true, + }; + } + } else { + // Create new room + roomData = { + roomId, + sessionId, + userId, + expertId: chosen.expertId, + createdAt: [ + new Date().toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + new Intl.DateTimeFormat('fa-IR-u-ca-persian').format(new Date()), + ], + messages: [], + isActive: true, + }; + } + await client.hset(this.ROOMS_HASH_KEY, roomId, JSON.stringify(roomData)); + + // Remove user from waiting queue after successful assignment + await this.removeUserFromQueue(userId); + + // === start of pending expert protection === + const pendingSeconds = Number(process.env.PENDING_EXPERT_SECONDS || '300'); + const PENDING_EXPERT_MS = pendingSeconds * 1000; + await client.set( + `session:${sessionId}:pendingExpertUntil`, + (Date.now() + PENDING_EXPERT_MS).toString(), + 'EX', + 60 * 60 // keep key for an hour + ); + await client.set(`session:${sessionId}:expertSeen`, 'false', 'EX', 60 * 60 * 24); + // === end of pending expert protection === + + // update Mongo (also store expert email in expert field) + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $set: { + connectedToExpert: true, + roomId, + onlineStartDate: new Date(), + onlineChatClosed: false, + expert: (await this.adminModel.findById(chosen.expertId).select('email').lean())?.email || String(chosen.expertId), + }, + }, + ); + + this.logger.log( + `assignExpert: user ${userId} assigned to expert ${chosen.expertId} (session ${sessionId})`, + ); + // Initialize inactivity tracker so scheduler can auto-close if no activity happens + await client.set( + `session:${sessionId}:lastActivity`, + Date.now().toString(), + 'EX', + 60 * 60 * 24, + ); + + return { + status: 'assigned', + sessionId, + roomId, + expertId: chosen.expertId, + socketId: chosen.socketId, + }; + } catch (err) { + this.logger.error('assignExpert error', err as any); + throw err; + } + } + + async getWaitingPosition(userId: string): Promise { + try { + const client = this.client(); + const queue = await client.lrange(this.WAITING_QUEUE_KEY, 0, -1); + // Parse each entry to extract the userId and find its index + const parsedQueue = queue.map(entry => { + try { + return JSON.parse(entry).userId; + } catch (e) { + return entry; // Fallback for old entries that might just be userId strings + } + }); + const idx = parsedQueue.indexOf(userId); + return idx >= 0 ? idx + 1 : 0; + } catch (err) { + this.logger.error('getWaitingPosition error', err as any); + return 0; + } + } + + async getWaitingUsers() { + const client = this.client(); + + try { + // 1️⃣ Fetch raw queued data from the WAITING_QUEUE_KEY (Redis list) + const rawQueue = await client.lrange(this.WAITING_QUEUE_KEY, 0, -1); + + if (!rawQueue.length) { + return []; + } + + // 2️⃣ Parse queued data and fetch user details for each userId + const waitingUsers = await Promise.all( + rawQueue.map(async (entry) => { + let userId: string; + let queuedAt: string | null = null; + + try { + const parsedEntry = JSON.parse(entry); + userId = parsedEntry.userId; + queuedAt = parsedEntry.queuedAt; + } catch (e) { + // If parsing fails, assume it's a plain userId string + userId = entry; + queuedAt = null; // No queuedAt available for old entries + } + + const user = await this.userModel + .findById(userId) + .select('_id name family mobile username') // Select specific fields, changed afamily to family + .lean(); + + if (user) { + return { + _id: user._id.toString(), + name: user.name, + family: user.family, + mobile: user.mobile, + username: user.username, + queuedAt, + }; + } + return null; + }), + ); + + return waitingUsers.filter(Boolean); // Filter out any nulls if user not found + } catch (err) { + this.logger.error('getWaitingUsers error', err as any); + return []; + } + } + + async assignFromQueue(): Promise< + Array<{ + userId: string; + sessionId: string; + roomId: string; + expertId: string; + socketId: string; + isTransfer?: boolean; + }> + > { + try { + const client = this.client(); + let nextUser = await client.lpop(this.WAITING_QUEUE_KEY); + const assignments: Array<{ + userId: string; + sessionId: string; + roomId: string; + expertId: string; + socketId: string; + isTransfer?: boolean; + fromExpertId?: string; + }> = []; + while (nextUser) { + const parsed = JSON.parse(nextUser) as WaitingQueueEntry; + const { userId, sessionId } = parsed; + + // Use the stored sessionId from the queue instead of finding any session + if (!sessionId) { + this.logger.warn(`assignFromQueue: missing sessionId for userId ${userId}, skipping`); + nextUser = await client.lpop(this.WAITING_QUEUE_KEY); + continue; + } + + // Check if user is still connected before assigning + const userSocketId = this.getConnectedSocketId(userId); + if (!userSocketId) { + this.logger.warn(`assignFromQueue: user ${userId} is not connected (disconnected), skipping session ${sessionId}`); + // Remove from queue since user is not connected + await this.removeUserFromQueue(userId); + // Try to close the session if it's not yet connected to expert + try { + const session = await this.sessionModel.findById(sessionId).select('connectedToExpert onlineChatClosed').lean(); + if (session && !session.connectedToExpert && !session.onlineChatClosed) { + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $set: { + onlineChatClosed: true, + onlineEndDate: new Date(), + chatClosed: true, + }, + }, + ); + this.logger.log(`assignFromQueue: Auto-closed session ${sessionId} for disconnected user ${userId}`); + } + } catch (err) { + this.logger.warn(`assignFromQueue: Failed to close session ${sessionId} for disconnected user`, err as any); + } + nextUser = await client.lpop(this.WAITING_QUEUE_KEY); + continue; + } + + // Validate that the session exists and matches the user + const sessionValidation = await this.validateSessionId(sessionId, userId); + if (!sessionValidation.valid) { + this.logger.warn(`assignFromQueue: invalid sessionId ${sessionId} for userId ${userId}, skipping`); + nextUser = await client.lpop(this.WAITING_QUEUE_KEY); + continue; + } + + // Check if session is already connected to an expert + const session = await this.sessionModel.findById(sessionId).lean(); + if (!session || session.connectedToExpert) { + this.logger.warn(`assignFromQueue: session ${sessionId} not found or already connected, skipping`); + nextUser = await client.lpop(this.WAITING_QUEUE_KEY); + continue; + } + + // assignFromQueue is called when experts come online or chats end, + // which typically happens during business hours, so pass true + const assigned = await this.assignExpert( + { + userId, + sessionId, + }, + true, + parsed.preferredExpertId + ? { preferredExpertId: parsed.preferredExpertId } + : undefined, + ); + + if (assigned.status !== 'assigned') { + // couldn't assign (no capacity or no experts available) — push back user and stop + await client.lpush(this.WAITING_QUEUE_KEY, nextUser); + break; + } + + // record assignment for caller to notify sockets + assignments.push({ + userId: parsed.userId, + sessionId: assigned.sessionId, + roomId: assigned.roomId, + expertId: assigned.expertId, + socketId: assigned.socketId, + isTransfer: parsed.source === 'transfer', + }); + + // Remove user from queue after successful assignment (also removes any duplicates) + await this.removeUserFromQueue(parsed.userId); + + // assigned successfully — continue to next user in queue + nextUser = await client.lpop(this.WAITING_QUEUE_KEY); + } + return assignments; + } catch (err) { + this.logger.error('assignFromQueue error', err as any); + return []; + } + } + + // ============================= + // ===== MESSAGE HANDLING ====== + // ============================= + + /** + * Ensures the given user or expert may upload a voice file for this session (HTTP upload before sendMessage). + */ + async assertParticipantCanUploadVoice( + sessionId: string, + participantId: string, + senderRole: 'User' | 'Expert', + ): Promise { + if (!Types.ObjectId.isValid(sessionId)) { + throw new BadRequestException('Invalid sessionId'); + } + const doc = await this.sessionModel + .findById(sessionId) + .lean() + .select('chatClosed onlineChatClosed userId expert'); + if (!doc) { + throw new BadRequestException(`Session not found: ${sessionId}`); + } + if ((doc as any).chatClosed === true || (doc as any).onlineChatClosed === true) { + throw new BadRequestException('Chat session is closed'); + } + if (senderRole === 'User') { + const uid = (doc as any).userId?.toString?.(); + if (!uid || uid !== String(participantId)) { + throw new ForbiddenException('Not allowed to upload voice for this session'); + } + return; + } + const expertId = await this.normalizeExpertId((doc as any).expert, sessionId); + if (!expertId || expertId !== String(participantId)) { + throw new ForbiddenException('Expert is not assigned to this session'); + } + } + + private isVoiceMessagePathForSession(message: string, sessionId: string): boolean { + if (!message || typeof message !== 'string') return false; + const sid = sessionId.replace(/[^a-fA-F0-9]/g, ''); + return message.includes(`/uploads/voices/${sid}/`) || message.includes(`/voices/${sid}/`); + } + + private isObjectStorageChatKey( + message: string, + sessionId: string, + fileType: 'voice' | 'image' | 'document', + ): boolean { + if (!message || typeof message !== 'string') return false; + const parts = message.split('/').filter(Boolean); + if (parts.length !== 5) return false; + if (parts[0] !== 'chats') return false; + if (parts[1] !== sessionId) return false; + if (!Types.ObjectId.isValid(parts[2])) return false; + if (parts[3] !== fileType) return false; + return true; + } + + private isMalformedChatsPrefix(messageText: string, sessionId: string): boolean { + const t = (messageText ?? '').trim(); + if (!t.startsWith('chats/')) return false; + return ( + !this.isObjectStorageChatKey(t, sessionId, 'voice') && + !this.isObjectStorageChatKey(t, sessionId, 'image') && + !this.isObjectStorageChatKey(t, sessionId, 'document') + ); + } + + private extractMessageIdFromChatKey(messageText: string): string | null { + const parts = messageText.split('/').filter(Boolean); + if (parts.length < 3 || parts[0] !== 'chats') return null; + return parts[2] || null; + } + + private async assertChatAttachmentMatches(params: { + sessionId: string; + messageId: string; + senderId: string; + senderRole: UserRole; + storageKey: string; + fileType: 'voice' | 'image' | 'document'; + }): Promise { + const doc = await this.chatAttachmentModel + .findOne({ + sessionId: new Types.ObjectId(params.sessionId), + messageId: new Types.ObjectId(params.messageId), + }) + .lean() + .exec(); + if (!doc) { + throw new Error('attachment_not_found_for_message'); + } + if (doc.storageKey !== params.storageKey) { + throw new Error('attachment_storage_key_mismatch'); + } + if (doc.fileType !== params.fileType) { + throw new Error('attachment_type_mismatch'); + } + if (String(doc.uploaderId) !== String(params.senderId)) { + throw new ForbiddenException('attachment_uploader_mismatch'); + } + if (doc.uploaderRole !== params.senderRole) { + throw new ForbiddenException('attachment_role_mismatch'); + } + } + + async saveMessage(payload: SendMessagePayload) { + const { roomId, sessionId, senderId, senderRole, message: messageText, replyToMessageId } = payload; + const client = this.client(); + const roomKey = `room:${sessionId}`; + const createdISO = new Date().toISOString(); + + try { + // 0️⃣ Guard: prevent messaging into closed sessions + const statusDoc = await this.sessionModel + .findById(sessionId) + .lean() + .select('chatClosed onlineChatClosed userId expert createdAt messages'); + if (!statusDoc) throw new Error(`Session not found: ${sessionId}`); + if ((statusDoc as any).chatClosed === true || (statusDoc as any).onlineChatClosed === true) { + throw new Error('Chat session is closed'); + } + + const messageType = payload.type ?? 'text'; + let voiceDurationSec: number | undefined = + payload.voiceDurationSec != null ? Number(payload.voiceDurationSec) : undefined; + if (voiceDurationSec != null && Number.isNaN(voiceDurationSec)) { + voiceDurationSec = undefined; + } + const voiceMimeType = payload.mimeType?.trim() || undefined; + + const legacyVoice = + messageType === 'voice' && + this.isVoiceMessagePathForSession(messageText, sessionId); + const objVoice = + messageType === 'voice' && + this.isObjectStorageChatKey(messageText, sessionId, 'voice'); + const objImage = + messageType === 'image' && + this.isObjectStorageChatKey(messageText, sessionId, 'image'); + const objDocument = + messageType === 'document' && + this.isObjectStorageChatKey(messageText, sessionId, 'document'); + + const presetMessageId = payload.presetMessageId?.trim(); + + let messageId: string; + + if (legacyVoice) { + messageId = new Types.ObjectId().toString(); + } else if (objVoice || objImage || objDocument) { + const fileType: 'voice' | 'image' | 'document' = objVoice + ? 'voice' + : objImage + ? 'image' + : 'document'; + const idFromKey = this.extractMessageIdFromChatKey(messageText); + if (!idFromKey || !Types.ObjectId.isValid(idFromKey)) { + throw new Error('invalid_attachment_message_id_in_key'); + } + if (!presetMessageId || presetMessageId !== idFromKey) { + throw new Error( + 'presetMessageId_required_and_must_match_object_storage_key', + ); + } + await this.assertChatAttachmentMatches({ + sessionId, + messageId: idFromKey, + senderId, + senderRole, + storageKey: messageText, + fileType, + }); + messageId = idFromKey; + } else if (messageType === 'voice') { + const t = (messageText ?? '').trim(); + if (t.startsWith('chats/')) { + const parts = t.split('/').filter(Boolean); + if (parts.length >= 2 && parts[1] !== sessionId) { + throw new Error( + `voice_storage_key_session_mismatch: key contains session ${parts[1]} but sendMessage sessionId is ${sessionId} — they must be the same as the upload endpoint session.`, + ); + } + } + throw new Error( + 'Invalid voice message: use object-storage key from upload API or legacy /uploads/voices// path', + ); + } else if (this.isMalformedChatsPrefix(messageText, sessionId)) { + throw new Error('invalid_object_storage_key'); + } else { + messageId = new Types.ObjectId().toString(); + } + + // 1️⃣ Try to fetch room from Redis + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + + // 0.5️⃣ Lightweight validation: Check if both parties are connected (in-memory check, very fast) + // This prevents sending messages to disconnected users/experts without adding significant load + if (roomRaw) { + try { + const room = JSON.parse(roomRaw) as RoomRedisData; + if (senderRole === 'Expert') { + // Expert sending message - check if user is connected + const userSocketId = this.getConnectedSocketId(room.userId); + if (!userSocketId) { + this.logger.warn(`saveMessage: User ${room.userId} is not connected, but allowing message (expert may reconnect user)`); + // Don't throw error - allow message to be saved, but log warning + } + } else if (senderRole === 'User') { + // User sending message - check if expert is connected + if (room.expertId) { + const expertSocketId = this.expertSockets.get(room.expertId); + if (!expertSocketId) { + this.logger.warn(`saveMessage: Expert ${room.expertId} is not connected, but allowing message (user may reconnect expert)`); + // Don't throw error - allow message to be saved, but log warning + } + } + } + } catch (err) { + // If validation fails, continue anyway (non-critical check) + this.logger.debug('saveMessage: Lightweight connection check failed, continuing', err as any); + } + } + let room: RoomRedisData | null = roomRaw + ? (JSON.parse(roomRaw) as RoomRedisData) + : null; + + // 2️⃣ If Redis room missing → bootstrap from Mongo + if (!room) { + const session = statusDoc; // already loaded above + + if (!session) throw new Error(`Session not found: ${sessionId}`); + + // Validate userId before creating room + const validatedUserId = await this.validateAndGetUserId(session, sessionId); + if (!validatedUserId) { + throw new Error(`Cannot create room: session ${sessionId} has invalid userId`); + } + + // Use session creation time, not current time + const createdAtValue = + session?.createdAt instanceof Date + ? session.createdAt.toISOString() + : session?.createdISO instanceof Date + ? session.createdISO.toISOString() + : new Date().toISOString(); + + const mongoMessages = + session?.messages?.map((m: any) => ({ + messageId: m.messageId?.toString?.() ?? new Types.ObjectId().toString(), + message: m.text ?? m.message, + sender: m.sender, + react: m.react ?? 'Nothing', + createdISO: m.createdISO || new Date().toISOString(), + createdAt: [ + new Date(m.createdISO).toLocaleTimeString('fa-IR', { + hour: '2-digit', + minute: '2-digit', + }), + new Intl.DateTimeFormat('fa-IR-u-ca-persian').format( + new Date(m.createdISO), + ), + ] as [string, string], + edited: !!m.edited, + replyTo: this.normalizeReplyToFromDoc(m.replyTo), + messageType: (m.messageType as 'text' | 'image' | 'voice' | 'document') || 'text', + voiceDurationSec: m.voiceDurationSec, + voiceMimeType: m.voiceMimeType, + mimeType: m.mimeType, + })) || []; + + // Normalize expertId from session.expert (which may be username/email or ObjectId string) + const expertIdFromSession = await this.normalizeExpertId( + (session as any)?.expert, + sessionId + ); + + room = { + roomId: roomKey, + sessionId, + userId: validatedUserId, + expertId: expertIdFromSession, + createdAt: [ + new Date(createdAtValue).toLocaleTimeString('fa-IR', { + hour: '2-digit', + minute: '2-digit', + }), + new Intl.DateTimeFormat('fa-IR-u-ca-persian').format(new Date(createdAtValue)), + ], + isActive: true, + messages: mongoMessages, + }; + + this.logger.log( + `Created Redis room from Mongo data for session ${sessionId}`, + ); + } + + // 3️⃣ Create new message (optional reply / quote to another message in this session) + let replyTo: MessageReplyToSnapshot | undefined; + if (replyToMessageId) { + if (!Types.ObjectId.isValid(replyToMessageId)) { + throw new Error('Invalid replyToMessageId'); + } + replyTo = this.buildReplyToSnapshot(replyToMessageId, room, statusDoc); + } + + const resolvedMessageType = + messageType === 'voice' + ? 'voice' + : messageType === 'image' + ? 'image' + : messageType === 'document' + ? 'document' + : 'text'; + + const newMessage: MessageRedis = { + messageId, + message: messageText, + sender: senderRole, + react: 'Nothing', + createdISO, + createdAt: [ + new Date(createdISO).toLocaleTimeString('fa-IR', { + hour: '2-digit', + minute: '2-digit', + }), + new Intl.DateTimeFormat('fa-IR-u-ca-persian').format(new Date(createdISO)), + ], + edited: false, + messageType: resolvedMessageType, + ...(messageType === 'voice' && + voiceDurationSec != null && + !Number.isNaN(voiceDurationSec) + ? { voiceDurationSec } + : {}), + ...(messageType === 'voice' && voiceMimeType ? { voiceMimeType } : {}), + ...((messageType === 'image' || messageType === 'document') && voiceMimeType + ? { mimeType: voiceMimeType } + : {}), + ...(replyTo ? { replyTo } : {}), + }; + + // 4️⃣ Append message to Redis room (in-memory) + room.messages.push(newMessage); + + // 5️⃣ Persist back to Redis + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + + // 5.5️⃣ Ensure expert/session mapping exists for onlineExperts and canonical SETs + if (room.expertId && Types.ObjectId.isValid(String(room.expertId))) { + await this.ensureExpertSessionMapping(String(room.expertId), sessionId); + } + + // Note: The "only one expert skipped" marker is cleared in refreshInactiveChatTimer() + // when called with isExpertMessage=true from the gateway after saveMessage + + // 6️⃣ Persist message to MongoDB session document + const pushDoc: Record = { + messageId: new Types.ObjectId(messageId), + text: messageText, + sender: senderRole, + createdISO: new Date(createdISO), + react: 'Nothing', + edited: false, + messageType: resolvedMessageType, + }; + if (messageType === 'voice') { + if (voiceDurationSec != null && !Number.isNaN(voiceDurationSec)) { + (pushDoc as any).voiceDurationSec = voiceDurationSec; + } + if (voiceMimeType) { + (pushDoc as any).voiceMimeType = voiceMimeType; + } + } + if ( + (messageType === 'image' || messageType === 'document') && + voiceMimeType + ) { + (pushDoc as any).mimeType = voiceMimeType; + } + if (replyTo) { + (pushDoc as any).replyTo = { + messageId: new Types.ObjectId(replyTo.messageId), + textPreview: replyTo.textPreview, + sender: replyTo.sender, + createdISO: new Date(this.normalizeCreatedISO(replyTo.createdISO)), + unavailable: false, + }; + } + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $push: { messages: pushDoc as any }, + }, + ); + + this.logger.log( + `Message saved successfully for session ${sessionId} (${senderRole})`, + ); + + // 7️⃣ Return unified message object for response + return { + roomId, + sessionId, + senderId, + senderRole, + messageId, + message: messageText, + createdISO, + type: resolvedMessageType, + ...(messageType === 'voice' && + voiceDurationSec != null && + !Number.isNaN(voiceDurationSec) + ? { voiceDurationSec } + : {}), + ...(messageType === 'voice' && voiceMimeType ? { mimeType: voiceMimeType } : {}), + ...((messageType === 'image' || messageType === 'document') && voiceMimeType + ? { mimeType: voiceMimeType } + : {}), + ...(replyTo ? { replyTo } : {}), + }; + } catch (err) { + this.logger.error('saveMessage error:', err); + throw new Error(`Failed to save message: ${err.message}`); + } + } + + async editMessage(payload: { + roomId: string; + sessionId: string; + messageId: string; + newText: string; + editorId: string; + editorRole: 'expert'; + }) { + const { roomId, sessionId, messageId, newText } = payload; + const client = this.client(); + this.logger.log(`editMessage: sessionId=${sessionId} roomId=${roomId} messageId=${messageId}`); + + // 1️⃣ Update Redis copy (if exists) — use canonical key room:${sessionId}, with fallback to provided roomId + const canonicalKey = `room:${sessionId}`; + let redisKeyUsed = canonicalKey; + let roomRaw = await client.hget(this.ROOMS_HASH_KEY, canonicalKey); + if (!roomRaw && roomId && roomId !== canonicalKey) { + const fallbackRaw = await client.hget(this.ROOMS_HASH_KEY, roomId); + if (fallbackRaw) { + roomRaw = fallbackRaw; + redisKeyUsed = roomId; + } + } + let updatedMessage = null; + + if (roomRaw) { + const room = JSON.parse(roomRaw); + const msgIndex = room.messages.findIndex((m: any) => m.messageId === messageId); + if (msgIndex !== -1) { + room.messages[msgIndex].message = newText; + room.messages[msgIndex].edited = true; + room.messages[msgIndex].editedAt = Date.now(); + + updatedMessage = room.messages[msgIndex]; + await client.hset(this.ROOMS_HASH_KEY, redisKeyUsed, JSON.stringify(room)); + } else { + this.logger.warn(`editMessage: messageId ${messageId} not found in Redis room ${redisKeyUsed}`); + } + } + + // 2️⃣ Update MongoDB version (robust to ObjectId vs string id) + const messageIdObj = Types.ObjectId.isValid(messageId) ? new Types.ObjectId(messageId) : null; + const filterPrimary: any = messageIdObj + ? { _id: sessionId, 'messages.messageId': messageIdObj } + : { _id: sessionId, 'messages.messageId': messageId }; + + const updateDoc = { + $set: { + 'messages.$.text': newText, + 'messages.$.edited': true, + 'messages.$.editedAt': new Date(), + }, + } as const; + + const res = await this.sessionModel.updateOne(filterPrimary, updateDoc); + if (!res?.modifiedCount) { + // Fallback: if we located the message index in Redis, update by index to bypass id mismatch + if (roomRaw) { + try { + const room = JSON.parse(roomRaw); + const idx = room.messages.findIndex((m: any) => m.messageId === messageId); + if (idx !== -1) { + const indexUpdate: any = { + $set: { + [`messages.${idx}.text`]: newText, + [`messages.${idx}.edited`]: true, + [`messages.${idx}.editedAt`]: new Date(), + }, + }; + await this.sessionModel.updateOne({ _id: sessionId }, indexUpdate); + } + } catch {} + } + } + + // 3️⃣ Return updated message + return ( + updatedMessage ?? { + messageId, + message: newText, + edited: true, + editedAt: Date.now(), + } + ); + } + + + async getChatHistory(sessionId: string) { + const client = this.client(); + const roomKey = `room:${sessionId}`; + + try { + // 1️⃣ Try Redis + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + let redisRoom: RoomRedisData | null = null; + if (roomRaw) { + redisRoom = JSON.parse(roomRaw) as RoomRedisData; + } + + // 2️⃣ Mongo session messages (AI/user history) + const session = await this.sessionModel + .findById(sessionId) + .lean() + .select("messages userId expert createdAt createdISO onlineStartDate"); + + const mongoMessages = + session?.messages?.map((m: any) => ({ + messageId: m.messageId?.toString?.() ?? new Types.ObjectId().toString(), + message: m.text ?? m.message, + sender: m.sender, + react: m.react ?? "Nothing", + createdISO: m.createdISO || new Date().toISOString(), + createdAt: [ + new Date(m.createdISO).toLocaleTimeString("fa-IR", { + hour: "2-digit", + minute: "2-digit", + }), + new Intl.DateTimeFormat("fa-IR-u-ca-persian").format( + new Date(m.createdISO), + ), + ] as [string, string], + edited: !!m.edited, + seenBy: {}, + replyTo: this.normalizeReplyToFromDoc(m.replyTo), + messageType: (m.messageType as 'text' | 'image' | 'voice' | 'document') || 'text', + voiceDurationSec: m.voiceDurationSec, + voiceMimeType: m.voiceMimeType, + })) || []; + + // 3️⃣ Redis messages (if any) + const redisMessages = + redisRoom?.messages?.map((m) => ({ + messageId: m.messageId, + message: m.message, + sender: m.sender, + createdISO: m.createdISO, + createdAt: m.createdAt, // already [string, string] + react: m.react ?? "Nothing", + edited: !!m.edited, + seenBy: (m as any).seenBy ?? {}, + replyTo: m.replyTo ? { ...m.replyTo } : undefined, + messageType: (m as MessageRedis).messageType || 'text', + voiceDurationSec: (m as MessageRedis).voiceDurationSec, + voiceMimeType: (m as MessageRedis).voiceMimeType, + })) || []; + + // 4️⃣ Merge with deduplication (prefer Redis version when both exist) + const mergedById = new Map(); + for (const m of mongoMessages) mergedById.set(m.messageId, m); + for (const m of redisMessages) mergedById.set(m.messageId, m); + const allMessages = Array.from(mergedById.values()).sort( + (a, b) => + new Date(a.createdISO).getTime() - new Date(b.createdISO).getTime(), + ); + const withReplyFlags = this.enrichReplyToAvailability( + allMessages as { messageId: string; replyTo?: MessageReplyToSnapshot }[], + ) as typeof allMessages; + + // 5️⃣ If Redis room not found, initialize it with Mongo messages + if (!redisRoom) { + // Validate userId before creating room + const validatedUserId = await this.validateAndGetUserId(session, sessionId); + if (!validatedUserId) { + this.logger.warn(`Cannot initialize room for session ${sessionId}: invalid userId`); + // Prepare response with createdAt and onlineStartDate + const createdAt = session?.createdAt || null; + let onlineStartDate: [string, string] | null = null; + + if (session?.onlineStartDate) { + onlineStartDate = TimeHelper.iso2PersianTimeAndDate(session.onlineStartDate); + } + + return { + history: withReplyFlags, + createdAt, + onlineStartDate, + }; + } + + // Normalize expertId + const normalizedExpertId = await this.normalizeExpertId(session?.expert, sessionId); + + // Use session creation time, not current time + const createdAtValue = + session?.createdAt instanceof Date + ? session.createdAt.toISOString() + : session?.createdISO instanceof Date + ? session.createdISO.toISOString() + : new Date().toISOString(); + + const newRoom: RoomRedisData = { + roomId: roomKey, + sessionId, + userId: validatedUserId, + expertId: normalizedExpertId, + createdAt: [ + new Date(createdAtValue).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + new Intl.DateTimeFormat('fa-IR-u-ca-persian').format(new Date(createdAtValue)), + ], + isActive: true, + messages: mongoMessages, + }; + + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(newRoom)); + + this.logger.log( + `Initialized Redis room with Mongo history for session ${sessionId}`, + ); + } + + // 6️⃣ Prepare response with createdAt and onlineStartDate + const createdAt = session?.createdAt || null; + let onlineStartDate: [string, string] | null = null; + + if (session?.onlineStartDate) { + onlineStartDate = TimeHelper.iso2PersianTimeAndDate(session.onlineStartDate); + } + + return { + history: withReplyFlags, + createdAt, + onlineStartDate, + }; + } catch (err) { + this.logger.error("getChatHistory error", err as any); + return { + history: [], + createdAt: null, + onlineStartDate: null, + }; + } + } + + + // ============================= + // ===== END CHAT / CLEANUP ==== + // ============================= + + async endChat(sessionId: string): Promise< + Array<{ + userId: string; + sessionId: string; + roomId: string; + expertId: string; + socketId: string; + isTransfer?: boolean; + }> + > { + try { + const client = this.client(); + const roomKey = `room:${sessionId}`; + + // Check if session is already closed before proceeding + const session = await this.sessionModel.findById(sessionId).select('onlineChatClosed chatClosed expert').lean(); + const wasAlreadyClosed = session && (session.onlineChatClosed || session.chatClosed); + + if (wasAlreadyClosed) { + this.logger.warn( + `endChat: session ${sessionId} is already closed (onlineChatClosed: ${session.onlineChatClosed}, chatClosed: ${session.chatClosed}). Cleaning Redis only.`, + ); + } + + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + + if (!roomRaw) { + // Room not in Redis but session exists + if (!wasAlreadyClosed) { + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $set: { + onlineChatClosed: true, + onlineEndDate: new Date(), + chatClosed: true, + }, + }, + ); + this.logger.warn( + `endChat: room ${roomKey} not found in Redis; updated session flags in Mongo`, + ); + } + + // CRITICAL: Even if room is missing, we must clean up any stale entries in expert SETs + // This handles cases where session was closed in Mongo but Redis SET wasn't cleaned + if (session?.expert) { + try { + const expertId = await this.normalizeExpertId(session.expert, sessionId); + if (expertId) { + await this.removeSessionFromExpertSet(expertId, sessionId); + this.logger.log(`endChat: Cleaned stale session ${sessionId} from expert ${expertId} SET (room was missing)`); + + // Sync expert hash if online + const expertRaw = await client.hget(this.ONLINE_EXPERTS_KEY, expertId); + if (expertRaw) { + await this.updateOnlineExpertHash(expertId, { includeSessionIdsFromSet: true, isOnline: true }); + } + } + } catch (err) { + this.logger.warn(`endChat: Failed to clean stale session from expert SET`, err as any); + } + } + + // Even if room object is missing, try to assign from queue + const assignments = await this.assignFromQueue(); + return assignments; + } + + const room = JSON.parse(roomRaw) as RoomRedisData; + + // Persist only messages that are not already stored in Mongo (by messageId) + const existing = await this.sessionModel + .findById(sessionId) + .select('messages.messageId') + .lean(); + const existingIds = new Set( + (existing?.messages || []).map((x: any) => (x.messageId?.toString?.() ?? String(x.messageId))) + ); + + const messagesToPersist = room.messages + .filter((m: any) => !existingIds.has(String(m.messageId))) + .map((m: any) => { + const base: any = { + // Keep the same shape as saveMessage to avoid type drift + messageId: String(m.messageId), + text: m.message, + sender: m.sender, + createdISO: String(m.createdISO), + react: m.react ?? 'Nothing', + edited: !!m.edited, + }; + if (m.replyTo?.messageId) { + base.replyTo = { + messageId: new Types.ObjectId(m.replyTo.messageId), + textPreview: m.replyTo.textPreview ?? '', + sender: m.replyTo.sender, + createdISO: new Date(this.normalizeCreatedISO(m.replyTo.createdISO)), + unavailable: !!m.replyTo.unavailable, + }; + } + base.messageType = m.messageType ?? 'text'; + if (m.voiceDurationSec != null && !Number.isNaN(Number(m.voiceDurationSec))) { + base.voiceDurationSec = Number(m.voiceDurationSec); + } + if (m.voiceMimeType) { + base.voiceMimeType = m.voiceMimeType; + } + return base; + }); + + // Only update Mongo if not already closed + if (!wasAlreadyClosed) { + await this.sessionModel.updateOne( + { _id: sessionId }, + { + ...(messagesToPersist.length + ? { $push: { messages: { $each: messagesToPersist } } } + : {}), + $set: { + onlineChatClosed: true, + onlineEndDate: new Date(), + chatClosed: true, + }, + }, + ); + } else if (messagesToPersist.length > 0) { + // If already closed but has new messages, persist them + await this.sessionModel.updateOne( + { _id: sessionId }, + { $push: { messages: { $each: messagesToPersist } } }, + ); + } + + // --- Expert cleanup --- + // Always remove the session from the canonical Redis SET even if the expert is offline + const remainingCount = await this.removeSessionFromExpertSet(room.expertId, sessionId); + + // If expert is currently online, also sync the hash entry from the SET + const expertRaw = await client.hget(this.ONLINE_EXPERTS_KEY, room.expertId); + if (expertRaw) { + try { + await this.updateOnlineExpertHash(room.expertId, { + includeSessionIdsFromSet: true, + activeSessions: remainingCount, + }); + } catch (err) { + this.logger.warn('endChat: failed to update onlineExperts hash for expert', err as any); + } + } else { + this.logger.warn(`endChat: expert ${room.expertId} not present in online experts (hash not synced)`); + } + + // remove room + await client.hdel(this.ROOMS_HASH_KEY, roomKey); + + // cleanup auxiliary per-session keys + try { + await Promise.all([ + client.del(`session:${sessionId}:expertSeen`), + client.del(`session:${sessionId}:pendingExpertUntil`), + client.del(`session:${sessionId}:pendingExpertFor`), + client.del(`${this.INACTIVE_PREFIX}${sessionId}`), + client.del(`${this.PENDING_PREFIX}${sessionId}`), + client.del(`session:${sessionId}:lastActivity`), + ]); + } catch {} + + this.logger.log(`endChat: session ${sessionId} persisted and cleaned from Redis (room and aux keys)`); + + // assign next waiting user + const assignments = await this.assignFromQueue(); + return assignments; + } catch (err) { + this.logger.error('endChat error', err as any); + throw err; + } + } + + /** + * Finds and force closes all active sessions for a user + * This is used when a user requests a new expert while having an active session + * @param userId - The user ID to find active sessions for + * @returns Array of closed session IDs + */ + async forceCloseUserActiveSessions(userId: string): Promise { + const closedSessionIds: string[] = []; + + try { + // 1. Check Redis for active sessions (findAll to catch multiple sessions) + const redisActiveSessions = await this.getUserActiveSessions(userId); + + if (redisActiveSessions.length > 0) { + this.logger.log(`[forceCloseUserActiveSessions] Found ${redisActiveSessions.length} active Redis sessions for user ${userId}, closing them`); + for (const session of redisActiveSessions) { + try { + await this.endChat(session.sessionId); + closedSessionIds.push(session.sessionId); + } catch (err) { + this.logger.error(`[forceCloseUserActiveSessions] Failed to close Redis session ${session.sessionId}`, err as any); + } + } + } + + // 2. Check MongoDB for any active sessions that might not be in Redis + const activeMongoSessions = await this.sessionModel.find({ + userId: new Types.ObjectId(userId), + connectedToExpert: true, + onlineChatClosed: false, + }).select('_id roomId').lean(); + + for (const session of activeMongoSessions) { + const sessionId = String(session._id); + + // Skip if we already closed this session from Redis check + if (closedSessionIds.includes(sessionId)) { + continue; + } + + this.logger.log(`[forceCloseUserActiveSessions] Found active MongoDB session ${sessionId} for user ${userId}, closing it`); + try { + await this.endChat(sessionId); + closedSessionIds.push(sessionId); + } catch (err) { + this.logger.error(`[forceCloseUserActiveSessions] Failed to close MongoDB session ${sessionId}`, err as any); + } + } + + // 3. Also check if user is in any expert's session SET (canonical source) + // This handles edge cases where session might be in expert SET but not in rooms + const client = this.client(); + const expertsRaw = await client.hgetall(this.ONLINE_EXPERTS_KEY); + + if (expertsRaw && Object.keys(expertsRaw).length > 0) { + for (const expertId of Object.keys(expertsRaw)) { + try { + // Use canonical Redis SET instead of hash array + const setKey = this.expertSessionsKey(expertId); + const sessionIds = await client.smembers(setKey); + + // Check each session to see if it belongs to this user + for (const sessionId of sessionIds) { + // Skip if already closed + if (closedSessionIds.includes(sessionId)) { + continue; + } + + try { + const session = await this.sessionModel.findById(sessionId).select('userId connectedToExpert onlineChatClosed').lean(); + if (session && String(session.userId) === userId) { + // Check if session is still active + if (session.connectedToExpert && !session.onlineChatClosed) { + // This session should have been closed but wasn't - close it now + this.logger.log(`[forceCloseUserActiveSessions] Found orphaned active session ${sessionId} in expert ${expertId} SET, closing it`); + try { + await this.endChat(sessionId); + closedSessionIds.push(sessionId); + } catch (err) { + this.logger.error(`[forceCloseUserActiveSessions] Failed to close orphaned session ${sessionId}`, err as any); + } + } + } + } catch (err) { + // Skip invalid session IDs + } + } + } catch (err) { + // Skip corrupted expert data + } + } + } + + if (closedSessionIds.length > 0) { + this.logger.log(`[forceCloseUserActiveSessions] Force closed ${closedSessionIds.length} active session(s) for user ${userId}: ${closedSessionIds.join(', ')}`); + } else { + this.logger.log(`[forceCloseUserActiveSessions] No active sessions found for user ${userId}`); + } + + return closedSessionIds; + } catch (err) { + this.logger.error(`[forceCloseUserActiveSessions] Error closing active sessions for user ${userId}`, err as any); + return closedSessionIds; // Return what we managed to close + } + } + + async handleDisconnectBySocket(socketId: string) { + try { + // remove from connectedClients + const pair = [...this.connectedClients.entries()].find(([, s]) => s === socketId); + if (pair) { + const userId = pair[0]; + this.connectedClients.delete(userId); + this.logger.log(`handleDisconnectBySocket: removed mapping ${userId} -> ${socketId}`); + } + // we intentionally do NOT change Redis expert online state here to avoid race conditions. + } catch (err) { + this.logger.error('handleDisconnectBySocket error', err as any); + } + } + + async getRoomDetails(roomKey: string) { + const client = this.client(); + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (!roomRaw) return null; + const room = JSON.parse(roomRaw); + + // Optional — enrich user/expert info + const user = await this.userModel.findById(room.userId).select('name family mobile'); + const expert = await this.adminModel.findById(room.expertId).select('name family role'); + + // Ensure messages are present similar to getChatHistory; if empty in Redis, hydrate from Mongo + if (!Array.isArray(room.messages) || room.messages.length === 0) { + try { + const sessionId = String(room.sessionId); + const session = await this.sessionModel + .findById(sessionId) + .lean() + .select('messages userId expert createdAt'); + const mongoMessages = (session?.messages || []).map((m: any) => ({ + messageId: m.messageId?.toString?.() ?? new Types.ObjectId().toString(), + message: m.text ?? m.message, + sender: m.sender, + react: m.react ?? 'Nothing', + createdISO: m.createdISO || new Date().toISOString(), + createdAt: [ + new Date(m.createdISO).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + new Intl.DateTimeFormat('fa-IR-u-ca-persian').format(new Date(m.createdISO)), + ] as [string, string], + edited: !!m.edited, + replyTo: this.normalizeReplyToFromDoc(m.replyTo), + messageType: (m.messageType as 'text' | 'image' | 'voice' | 'document') || 'text', + voiceDurationSec: m.voiceDurationSec, + voiceMimeType: m.voiceMimeType, + })); + room.messages = mongoMessages; + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + } catch (err) { + this.logger.warn('getRoomDetails: failed to hydrate messages from Mongo', err as any); + } + } + + return { ...room, user, expert }; + } + + async getExpertActiveSessions(expertId: string) { + const client = this.client(); + + // 1) Read expert entry + const expertRaw = await client.hget(this.ONLINE_EXPERTS_KEY, expertId); + if (!expertRaw) return { expertId, sessions: [] }; + + let expert: ExpertRedisData; + try { + expert = JSON.parse(expertRaw) as ExpertRedisData; + } catch (err) { + this.logger.warn('getExpertActiveSessions: failed parse expert raw', err); + return { expertId, sessions: [] }; + } + + // Normalize sessionIds (dedupe and filter) and prefer canonical Redis Set if present + try { + const setSessionIds = await this.getExpertSessionIdsFromSet(expertId); + if (setSessionIds.length) { + expert.sessionIds = [...new Set(setSessionIds)]; + } else { + expert.sessionIds = Array.isArray(expert.sessionIds) + ? [...new Set(expert.sessionIds.filter(Boolean))] + : []; + } + } catch { + expert.sessionIds = Array.isArray(expert.sessionIds) + ? [...new Set(expert.sessionIds.filter(Boolean))] + : []; + } + + // 2) For each sessionId build summary object + const sessions = await Promise.all( + expert.sessionIds.map(async (sessionId) => { + const roomKey = `room:${sessionId}`; + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + + // base structure + const sessionInfo: any = { + sessionId, + roomId: roomKey, + user: null, + totalMessages: 0, + unseenByExpert: 0, + }; + + // Always read Mongo for authoritative message count (per requirement) + const mongoSession = await this.sessionModel + .findById(sessionId) + .lean() + .select('userId messages onlineStartDate'); + + if (mongoSession) { + sessionInfo.totalMessages = (mongoSession.messages || []).length; + // prefer userId from Redis room if present; otherwise from Mongo + sessionInfo.user = sessionInfo.user ?? mongoSession.userId?.toString?.() ?? null; + } + + if (roomRaw) { + const room = JSON.parse(roomRaw) as RoomRedisData; + // prefer Redis room userId when available + sessionInfo.user = room.userId ?? sessionInfo.user; + // count unseen messages for expert — ONLY user-sent messages not seen by expert + sessionInfo.unseenByExpert = (room.messages || []).reduce((acc: number, m: any) => { + const seenBy = m.seenBy ?? {}; + const senderNormalized = String(m.sender).toLowerCase(); + const sentByUser = senderNormalized === 'user'; + if (sentByUser && !seenBy.expert) return acc + 1; + return acc; + }, 0); + // include full messages for UI + const roomMsgs = (room.messages || []).map((m: any) => ({ + messageId: m.messageId, + message: m.message, + sender: m.sender, + createdISO: m.createdISO, + seenBy: m.seenBy ?? {}, + edited: !!m.edited, + replyTo: m.replyTo ? { ...m.replyTo } : undefined, + messageType: m.messageType || 'text', + voiceDurationSec: m.voiceDurationSec, + voiceMimeType: m.voiceMimeType, + })); + sessionInfo.messages = this.enrichReplyToAvailability( + roomMsgs as { messageId: string; replyTo?: MessageReplyToSnapshot }[], + ) as any; + } else { + // Calculate unseen messages from MongoDB when Redis room doesn't exist + // Only count user messages sent during online chat period (after onlineStartDate) + if (mongoSession && mongoSession.onlineStartDate) { + const onlineStartDate = new Date(mongoSession.onlineStartDate); + sessionInfo.unseenByExpert = (mongoSession.messages || []).reduce((acc: number, m: any) => { + const senderNormalized = String(m.sender).toLowerCase(); + const sentByUser = senderNormalized === 'user'; + const messageDate = m.createdISO ? new Date(m.createdISO) : null; + const sentAfterOnlineStart = messageDate && messageDate >= onlineStartDate; + if (sentByUser && sentAfterOnlineStart) return acc + 1; + return acc; + }, 0); + } else { + // If no onlineStartDate, we can't distinguish online vs AI messages, so default to 0 + sessionInfo.unseenByExpert = 0; + } + // fallback — provide full messages from Mongo + if (mongoSession) { + const mongoForExpert = (mongoSession.messages || []).map((m: any) => ({ + messageId: String(m.messageId?.toString?.() ?? ''), + message: m.text, + sender: m.sender, + createdISO: m.createdISO, + seenBy: { user: true, expert: false }, + edited: !!m.edited, + replyTo: this.normalizeReplyToFromDoc(m.replyTo), + messageType: (m.messageType as 'text' | 'image' | 'voice' | 'document') || 'text', + voiceDurationSec: m.voiceDurationSec, + voiceMimeType: m.voiceMimeType, + })); + sessionInfo.messages = this.enrichReplyToAvailability( + mongoForExpert as { messageId: string; replyTo?: MessageReplyToSnapshot }[], + ) as any; + } + } + + // fetch user details for UI (optional) + if (sessionInfo.user) { + try { + const u = await this.userModel.findById(sessionInfo.user).select('_id name family mobile').lean(); + if (u) sessionInfo.user = { _id: u._id.toString(), name: u.name, family: u.family, mobile: u.mobile }; + } catch (err) { + /* ignore */ + } + } + + return sessionInfo; + }), + ); + + // 3) Compute aggregated unseen total + const unseenTotal = sessions.reduce((acc: number, s: any) => acc + (s.unseenByExpert || 0), 0); + + return { expertId, sessions, unseenTotal, expertMeta: { activeSessions: expert.activeSessions, maxSessions: expert.maxSessions } }; + } + + async markAllExpertSessionsSeen(expertId: string) { + const client = this.client(); + + const expertRaw = await client.hget(this.ONLINE_EXPERTS_KEY, expertId); + if (!expertRaw) return { expertId, updated: 0 }; + + let expert: ExpertRedisData; + try { + expert = JSON.parse(expertRaw) as ExpertRedisData; + } catch { + return { expertId, updated: 0 }; + } + + const sessionIds = Array.isArray(expert.sessionIds) ? [...new Set(expert.sessionIds.filter(Boolean))] : []; + let totalUpdated = 0; + + for (const sessionId of sessionIds) { + const res = await this.markMessagesSeen(sessionId, 'expert'); + totalUpdated += res.updated || 0; + } + + return { expertId, updated: totalUpdated }; + } + + async checkAndReassignInactiveExperts() { + const client = this.client(); + + // scan keys + // keys pattern: session::pendingExpertUntil + const keys = await client.keys('session:*:pendingExpertUntil'); + const reassignments: Array<{ sessionId: string; oldExpertId: string | null; newExpertId: string | null; userId?: string; reason?: string }> = []; + + for (const k of keys) { + try { + const sessionId = k.split(':')[1]; + const pendingUntilRaw = await client.get(k); + const pendingUntil = pendingUntilRaw ? parseInt(pendingUntilRaw, 10) : 0; + if (!pendingUntil) { + // corrupt key — remove it + await client.del(k); + continue; + } + + if (Date.now() < pendingUntil) continue; // still waiting + + // Check if expert has marked seen already + const seenKey = `session:${sessionId}:expertSeen`; + const seen = await client.get(seenKey); + if (seen === 'true') { + // expert already saw — cancel pending marker + await client.del(k); + continue; + } + + // Find current assigned expert (from room) + const roomKey = `room:${sessionId}`; + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + const oldExpertId = roomRaw ? (JSON.parse(roomRaw) as RoomRedisData).expertId : null; + + // Try reassign to another available expert + const expertsRaw = await client.hgetall(this.ONLINE_EXPERTS_KEY); + const experts: ExpertRedisData[] = Object.values(expertsRaw) + .map((v) => { + try { return JSON.parse(v as string) as ExpertRedisData; } catch { return null as any; } + }) + .filter(Boolean); + + // Detect if there is actually any *other* online expert besides the current one + const otherOnlineExperts = experts.filter( + (e) => e.isOnline && e.expertId !== oldExpertId, + ); + + // Only reassign if there is at least one alternative expert who has capacity + const available = otherOnlineExperts.filter( + (e) => + (e.activeSessions || 0) < + (e.maxSessions || this.MAX_SESSIONS_PER_EXPERT), + ); + + if (available.length === 0) { + // No alternative expert with capacity. + // If there are *no* other online experts at all (only this expert is online), + // we keep the session assigned to the current expert and just drop the pending marker. + // We also set a marker to track this situation for later logging when session closes. + if (otherOnlineExperts.length === 0) { + this.logger.log( + `[checkAndReassignInactiveExperts] Only one online expert (${oldExpertId}) for session ${sessionId}. Skipping reassignment and keeping expert until inactivity auto-close.`, + ); + // Set a marker to track that this session was in "only one expert" state + // This will be checked in checkAndCloseInactiveChats to log reassignment + if (oldExpertId) { + await client.set( + `session:${sessionId}:onlyOneExpertSkipped`, + oldExpertId, + 'EX', + 86400, // 24 hours expiry + ); + } + await client.del(k); // clear pending marker so we don't repeatedly try + continue; + } + + // There are other experts online but none currently have capacity. + // Add session to waiting queue so it can be reassigned when capacity opens. + if (oldExpertId) { + try { + // Get session to find userId + const session = await this.sessionModel + .findById(sessionId) + .select('userId expert connectedToExpert') + .lean(); + if (session) { + const userId = String(session.userId); + + // Remove session from old expert's SET + try { + await this.removeSessionFromExpertSet(oldExpertId, sessionId); + // Sync old expert's hash + const expertRaw = await client.hget( + this.ONLINE_EXPERTS_KEY, + oldExpertId, + ); + if (expertRaw) { + await this.updateOnlineExpertHash(oldExpertId, { + includeSessionIdsFromSet: true, + isOnline: true, + }); + } + } catch (err) { + this.logger.warn( + `[checkAndReassignInactiveExperts] Failed to remove session from old expert SET`, + err as any, + ); + } + + // Update room to remove expertId but keep room active + if (roomRaw) { + try { + const room = JSON.parse(roomRaw) as RoomRedisData; + room.expertId = null; // No expert assigned, session will be queued + await client.hset( + this.ROOMS_HASH_KEY, + roomKey, + JSON.stringify(room), + ); + this.logger.log( + `[checkAndReassignInactiveExperts] Updated room ${roomKey}: removed expertId, adding to waiting queue`, + ); + } catch (err) { + this.logger.warn( + `[checkAndReassignInactiveExperts] Failed to update room ${roomKey}`, + err as any, + ); + } + } + + // Update MongoDB session to unset expert and set connectedToExpert = false + try { + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $unset: { expert: '' }, + $set: { connectedToExpert: false }, + }, + ); + } catch (err) { + this.logger.warn( + `[checkAndReassignInactiveExperts] Failed to update MongoDB session`, + err as any, + ); + } + + // Add to waiting queue so it can be reassigned when capacity opens + await this.enqueueUser(userId, sessionId); + await this.addWaitingUser(userId, sessionId); + this.logger.log( + `[checkAndReassignInactiveExperts] No expert with capacity available for session ${sessionId}. Added to waiting queue. Session will be reassigned when capacity opens.`, + ); + } + } catch (err) { + this.logger.error( + `[checkAndReassignInactiveExperts] Failed to queue session ${sessionId}`, + err as any, + ); + } + } + // Remove pending marker since we're not reassigning + await client.del(k); + continue; + } + + // pick least busy + available.sort((a, b) => (a.activeSessions || 0) - (b.activeSessions || 0)); + const newExpert = available[0]; + + // CRITICAL: First, find which experts actually have this session in their SETs + // Do this BEFORE adding to new expert to avoid confusion + const expertsWithSession: string[] = []; + for (const expert of experts) { + if (expert.expertId !== newExpert.expertId) { + try { + const setKey = this.expertSessionsKey(expert.expertId); + const isMember = await client.sismember(setKey, sessionId); + if (isMember) { + expertsWithSession.push(expert.expertId); + this.logger.log(`[checkAndReassignInactiveExperts] Found session ${sessionId} in expert ${expert.expertId} SET before reassignment`); + } + } catch (err) { + this.logger.warn(`Failed to check if session ${sessionId} is in expert ${expert.expertId} SET`, err as any); + } + } + } + + // Remove session from all experts that currently have it (except new expert) + const expertsToSync: string[] = []; + for (const expertId of expertsWithSession) { + try { + const setKey = this.expertSessionsKey(expertId); + await client.srem(setKey, sessionId); + expertsToSync.push(expertId); + this.logger.log(`[checkAndReassignInactiveExperts] Removed session ${sessionId} from expert ${expertId} SET before reassignment`); + } catch (err) { + this.logger.warn(`Failed to remove session ${sessionId} from expert ${expertId} SET`, err as any); + // Don't abort - continue with reassignment even if some removals fail + } + } + + // NOW add to new expert's SET (after removing from all others) + try { + const newSetKey = this.expertSessionsKey(newExpert.expertId); + // Check if it's already there (shouldn't be, but be safe) + const alreadyInNewExpert = await client.sismember(newSetKey, sessionId); + if (!alreadyInNewExpert) { + await client.sadd(newSetKey, sessionId); + this.logger.log(`[checkAndReassignInactiveExperts] Added session ${sessionId} to new expert ${newExpert.expertId} SET`); + } else { + this.logger.warn(`[checkAndReassignInactiveExperts] Session ${sessionId} already in new expert ${newExpert.expertId} SET - skipping add`); + } + } catch (err) { + this.logger.error(`Failed to add session ${sessionId} to new expert ${newExpert.expertId} SET - aborting reassignment`, err as any); + // If we can't add to new expert, try to restore to old experts + for (const expertId of expertsWithSession) { + try { + const setKey = this.expertSessionsKey(expertId); + await client.sadd(setKey, sessionId); + this.logger.warn(`[checkAndReassignInactiveExperts] Restored session ${sessionId} to expert ${expertId} SET after failed reassignment`); + } catch (restoreErr) { + this.logger.error(`Failed to restore session ${sessionId} to expert ${expertId} SET`, restoreErr as any); + } + } + continue; // Skip this reassignment + } + + // Sync onlineExperts hash from canonical sets for new expert + try { + await this.updateOnlineExpertHash(newExpert.expertId, { includeSessionIdsFromSet: true, isOnline: true }); + } catch (err) { + this.logger.warn('Failed to sync new expert hash from SET', err as any); + } + + // update room expertId - preserve existing room data + if (roomRaw) { + try { + const room = JSON.parse(roomRaw) as RoomRedisData; + const oldExpertIdFromRoom = room.expertId; + room.expertId = newExpert.expertId; + room.isActive = true; // Ensure room is marked as active + // Preserve all existing room data (messages, createdAt, userId, etc.) + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + this.logger.log(`[checkAndReassignInactiveExperts] Updated room ${roomKey}: expertId changed from ${oldExpertIdFromRoom} to ${newExpert.expertId}`); + } catch (err) { + this.logger.warn(`[checkAndReassignInactiveExperts] Failed to update room ${roomKey}`, err as any); + } + } else { + this.logger.warn(`[checkAndReassignInactiveExperts] No room found for session ${sessionId} during reassignment`); + } + + // Update MongoDB session document with new expert (same as in assignExpert) + try { + const newExpertEmail = (await this.adminModel.findById(newExpert.expertId).select('email').lean())?.email || String(newExpert.expertId); + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $set: { + expert: newExpertEmail, + }, + }, + ); + this.logger.log(`[checkAndReassignInactiveExperts] Updated MongoDB session ${sessionId} with new expert: ${newExpertEmail}`); + } catch (err) { + this.logger.warn(`[checkAndReassignInactiveExperts] Failed to update MongoDB session ${sessionId} with new expert`, err as any); + } + + // Sync all experts that had the session removed (to update their sessionIds & counts) + for (const expertId of expertsToSync) { + try { + await this.updateOnlineExpertHash(expertId, { includeSessionIdsFromSet: true, isOnline: true }); + } catch (err) { + this.logger.warn(`Failed to sync expert ${expertId} hash from SET after session removal`, err as any); + } + } + + // remove pending marker + await client.del(k); + + // Log the reassignment + if (oldExpertId) { + try { + // Get session to find userId + const session = await this.sessionModel.findById(sessionId).select('userId').lean(); + if (session) { + const userId = String(session.userId); + // Get expert emails + const oldExpert = await this.adminModel.findById(oldExpertId).select('email').lean(); + const newExpertDoc = await this.adminModel.findById(newExpert.expertId).select('email').lean(); + const oldExpertEmail = oldExpert?.email || String(oldExpertId); + const newExpertEmail = newExpertDoc?.email || String(newExpert.expertId); + + // Log reassignment from old expert to new expert + await this.logReassignment(sessionId, userId, oldExpertEmail, newExpertEmail); + } + } catch (err) { + this.logger.error(`[checkAndReassignInactiveExperts] Failed to log reassignment for session ${sessionId}`, err as any); + } + } + + reassignments.push({ sessionId, oldExpertId, newExpertId: newExpert.expertId }); + } catch (err) { + this.logger.warn('checkAndReassignInactiveExperts loop error', err as any); + } + } + + if (reassignments.length) { + this.logger.log(`Reassigned ${reassignments.length} sessions due to inactive experts`); + } + return reassignments; + } + + async markMessagesSeen(sessionId: string, viewerRole: 'user' | 'expert') { + const client = this.client(); + const roomKey = `room:${sessionId}`; + + // 1) load room from Redis + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (!roomRaw) { + // nothing to mark as seen if no redis room exists + return { sessionId, updated: 0 }; + } + + const room = JSON.parse(roomRaw) as RoomRedisData; + let updatedCount = 0; + + // 2) iterate messages and mark seen for viewerRole where appropriate + for (let i = 0; i < (room.messages || []).length; i++) { + const msg = room.messages[i] as any; + + // initialize seenBy structure + if (!msg.seenBy || typeof msg.seenBy !== 'object') msg.seenBy = {}; + + // Only mark messages **not sent by the viewerRole** as seen. + const senderRoleNormalized = String(msg.sender).toLowerCase(); + const viewerIsExpert = viewerRole === 'expert'; + const msgSentByViewer = (viewerIsExpert && senderRoleNormalized === 'expert') || (!viewerIsExpert && senderRoleNormalized === 'user'); + + if (!msgSentByViewer && !msg.seenBy[viewerRole]) { + msg.seenBy[viewerRole] = true; + updatedCount++; + } + } + + // 3) persist updated room back to Redis + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + + // 4) (defer Mongo persistence to endChat) — but we may optionally update metadata counters in Mongo here if you want + // Return summary + return { sessionId, updated: updatedCount }; + } + + /** + * Mark messages as seen that were created BEFORE a specific timestamp. + * Used when expert joins room - only mark old messages, not new ones sent after join. + */ + async markMessagesSeenBeforeTimestamp( + sessionId: string, + viewerRole: 'user' | 'expert', + beforeTimestamp: Date, + ) { + const client = this.client(); + const roomKey = `room:${sessionId}`; + + // 1) load room from Redis + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (!roomRaw) { + // nothing to mark as seen if no redis room exists + return { sessionId, updated: 0 }; + } + + const room = JSON.parse(roomRaw) as RoomRedisData; + let updatedCount = 0; + const beforeTime = beforeTimestamp.getTime(); + + // 2) iterate messages and mark seen ONLY for messages created BEFORE the timestamp + for (let i = 0; i < (room.messages || []).length; i++) { + const msg = room.messages[i] as any; + + // Skip messages created AFTER the timestamp + const msgTime = msg.createdISO ? new Date(msg.createdISO).getTime() : 0; + if (msgTime >= beforeTime) { + continue; // Don't mark new messages sent after expert joined + } + + // initialize seenBy structure + if (!msg.seenBy || typeof msg.seenBy !== 'object') msg.seenBy = {}; + + // Only mark messages **not sent by the viewerRole** as seen. + const senderRoleNormalized = String(msg.sender).toLowerCase(); + const viewerIsExpert = viewerRole === 'expert'; + const msgSentByViewer = (viewerIsExpert && senderRoleNormalized === 'expert') || (!viewerIsExpert && senderRoleNormalized === 'user'); + + if (!msgSentByViewer && !msg.seenBy[viewerRole]) { + msg.seenBy[viewerRole] = true; + updatedCount++; + } + } + + // 3) persist updated room back to Redis + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + + // Return summary + return { sessionId, updated: updatedCount }; + } + + async autoMarkIfViewerOnline( + roomId: string, + viewerRole: 'user' | 'expert', + connectedRoomSockets: string[], + ) { + try { + if (connectedRoomSockets.length > 0) { + const sessionId = roomId.replace('room:', ''); + await this.markMessagesSeen(sessionId, viewerRole); + } + } catch (err) { + console.error('autoMarkIfViewerOnline error:', err); + } + } + + async checkAndCloseInactiveChats() { + const client = this.client(); + // We store lastActivity as timestamp string in session::lastActivity + const keys = await client.keys('session:*:lastActivity'); + const closed: Array<{ sessionId: string; userId: string | null }> = []; + + const envSeconds = Number(process.env.CHAT_INACTIVE_CLOSE_SECONDS || '300'); + const INACTIVE_CLOSE_MS = envSeconds * 1000; + + for (const k of keys) { + try { + const sessionId = k.split(':')[1]; + const last = await client.get(k); + const lastTs = last ? parseInt(last, 10) : 0; + if (!lastTs) continue; + if (Date.now() - lastTs > INACTIVE_CLOSE_MS) { + // Get userId from room before closing (room will be deleted in endChat) + let userId: string | null = null; + try { + const roomKey = `room:${sessionId}`; + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (roomRaw) { + const room = JSON.parse(roomRaw) as RoomRedisData; + userId = room.userId || null; + } + } catch (err) { + this.logger.warn('Failed to get userId from room before closing', sessionId, err as any); + } + + // Check if this session was in "only one expert" state before closing + // If so, log reassignment to null (expert didn't answer) + // BUT: Only log if expert hasn't sent any messages (expert actually didn't answer) + try { + const onlyOneExpertMarker = await client.get(`session:${sessionId}:onlyOneExpertSkipped`); + if (onlyOneExpertMarker && userId) { + // Check if room has expert messages before logging "didn't answer" + let hasExpertMessages = false; + try { + const roomKey = `room:${sessionId}`; + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (roomRaw) { + const room = JSON.parse(roomRaw) as RoomRedisData; + // Check if any messages are from expert + hasExpertMessages = (room.messages || []).some( + (msg) => msg.sender?.toLowerCase() === 'expert' || msg.sender === 'Expert', + ); + } + } catch (err) { + // If we can't check room, also check MongoDB session as fallback + try { + const session = await this.sessionModel.findById(sessionId).select('messages').lean(); + if (session && (session as any).messages) { + hasExpertMessages = (session as any).messages.some( + (msg: any) => msg.sender?.toLowerCase() === 'expert' || msg.sender === 'Expert', + ); + } + } catch (mongoErr) { + this.logger.warn(`[checkAndCloseInactiveChats] Failed to check messages from MongoDB for session ${sessionId}`, mongoErr as any); + } + } + + if (!hasExpertMessages) { + // Expert truly didn't answer - log reassignment to null + const oldExpertId = onlyOneExpertMarker; + const oldExpert = await this.adminModel.findById(oldExpertId).select('email').lean(); + const oldExpertEmail = oldExpert?.email || String(oldExpertId); + + await this.logReassignment(sessionId, userId, oldExpertEmail, null); + this.logger.log( + `[checkAndCloseInactiveChats] Logged reassignment to null for session ${sessionId} that was in "only one expert" state (expert ${oldExpertId} didn't answer)`, + ); + } else { + // Expert did send messages - don't log as "didn't answer" + this.logger.log( + `[checkAndCloseInactiveChats] Skipped logging reassignment to null for session ${sessionId} because expert sent messages (expert did answer)`, + ); + } + + // Delete the marker after processing (whether we logged or not) + await client.del(`session:${sessionId}:onlyOneExpertSkipped`); + } + } catch (err) { + this.logger.warn('Failed to check/process onlyOneExpertSkipped marker', sessionId, err as any); + } + + // close chat by calling your endChat logic (which persists and cleans up) + try { + await this.endChat(sessionId); + closed.push({ sessionId, userId }); + } catch (err) { + this.logger.warn('Failed auto-close session', sessionId, err as any); + } + } + } catch (err) { + this.logger.warn('checkAndCloseInactiveChats loop error', err as any); + } + } + + if (closed.length) { + this.logger.log(`Auto-closed ${closed.length} inactive sessions`); + } + return closed; + } + + async checkAndOfflineInactiveExperts() { + const client = this.client(); + try { + const entries = await client.hgetall(this.ONLINE_EXPERTS_KEY); + if (!entries || Object.keys(entries).length === 0) return [] as string[]; + + const thresholdMs = Number(process.env.EXPERT_INACTIVE_OFFLINE_SECONDS || '900') * 1000; + const now = Date.now(); + const offlined: string[] = []; + + for (const expertId of Object.keys(entries)) { + try { + const disconnectedAt = await client.get(`expert:${expertId}:disconnectedAt`); + if (disconnectedAt) { + continue; + } + + const activeIds = await this.filterActiveSessions( + await this.getExpertSessionIdsFromSet(expertId), + ); + if (activeIds.length > 0) { + continue; + } + + const last = await client.get(`expert:${expertId}:lastActive`); + const lastTs = last ? parseInt(last, 10) : 0; + if (!lastTs || now - lastTs > thresholdMs) { + await this.setExpertOffline(expertId); + offlined.push(expertId); + } + } catch (err) { + this.logger.warn('checkAndOfflineInactiveExperts per-expert error', err as any); + } + } + + if (offlined.length) { + this.logger.log(`Auto-offlined ${offlined.length} inactive experts`); + } + return offlined; + } catch (err) { + this.logger.error('checkAndOfflineInactiveExperts error', err as any); + return [] as string[]; + } + } + + async refreshExpertLastActive(expertId: string) { + try { + const r = this.client(); + await r.set(`expert:${expertId}:lastActive`, Date.now().toString(), 'EX', 60 * 60 * 24); + } catch (err) { + this.logger.warn('refreshExpertLastActive error', err as any); + } + } + + +async repairRedisConsistency({ cleanStale = false }: { cleanStale?: boolean } = {}) { + try { + const client = this.client(); + + // First, fix duplicate sessions across expert SETs + const duplicateFixes = await this.fixDuplicateSessionsAcrossExperts(); + + const expertsRaw = await client.hgetall(this.ONLINE_EXPERTS_KEY); + const roomsRaw = await client.hgetall(this.ROOMS_HASH_KEY); + + // Health check: Detect and clean orphaned sessions (rooms where user/expert is disconnected) + const orphanedSessions: string[] = []; + if (roomsRaw && Object.keys(roomsRaw).length > 0) { + this.logger.log(`[repairRedisConsistency] Checking ${Object.keys(roomsRaw).length} rooms for orphaned sessions`); + for (const [roomKey, raw] of Object.entries(roomsRaw)) { + try { + const room = JSON.parse(raw) as RoomRedisData; + const sessionId = room.sessionId; + + // Check if user is connected + const userSocketId = this.getConnectedSocketId(room.userId); + const isUserConnected = !!userSocketId; + + // Check if expert is connected + let isExpertConnected = false; + if (room.expertId) { + const expertSocketId = this.expertSockets.get(room.expertId); + isExpertConnected = !!expertSocketId; + } + + // If neither party is connected, mark as orphaned + if (!isUserConnected && !isExpertConnected) { + // Verify session exists and is still active in MongoDB + try { + const session = await this.sessionModel.findById(sessionId).select('connectedToExpert onlineChatClosed').lean(); + if (session && session.connectedToExpert && !session.onlineChatClosed) { + orphanedSessions.push(sessionId); + this.logger.warn(`[repairRedisConsistency] Found orphaned session ${sessionId}: both user ${room.userId} and expert ${room.expertId || 'none'} are disconnected`); + } + } catch (err) { + // Session doesn't exist or error - skip + } + } + } catch (err) { + // Skip corrupted room data + this.logger.debug(`[repairRedisConsistency] Error checking room ${roomKey}`, err as any); + } + } + + // Clean up orphaned sessions + if (orphanedSessions.length > 0 && cleanStale) { + this.logger.warn(`[repairRedisConsistency] Cleaning up ${orphanedSessions.length} orphaned sessions`); + for (const sessionId of orphanedSessions) { + try { + // Close the session properly + await this.endChat(sessionId); + this.logger.log(`[repairRedisConsistency] Closed orphaned session ${sessionId}`); + } catch (err) { + this.logger.error(`[repairRedisConsistency] Failed to close orphaned session ${sessionId}`, err as any); + } + } + } else if (orphanedSessions.length > 0) { + this.logger.warn(`[repairRedisConsistency] Found ${orphanedSessions.length} orphaned sessions (use cleanStale=true to clean them)`); + } + } + + if (!expertsRaw || Object.keys(expertsRaw).length === 0) { + this.logger.warn('repairRedisConsistency: no experts found'); + const result: any = duplicateFixes.length > 0 ? { duplicateFixes } : undefined; + if (orphanedSessions.length > 0) { + return { ...(result || {}), orphanedSessions: orphanedSessions.length }; + } + return result; + } + + const repaired: { expertId: string; fixes: string[] }[] = []; + + // Build a quick lookup for existing room sessions + const existingRoomIds = new Set(Object.keys(roomsRaw || {})); + const existingSessionIds = new Set( + Array.from(existingRoomIds).map((k) => k.replace(/^room:/, '')), + ); + + for (const [expertId, raw] of Object.entries(expertsRaw)) { + let expert: ExpertRedisData; + const fixes: string[] = []; + + try { + expert = JSON.parse(raw); + } catch { + fixes.push('Corrupted JSON — entry removed'); + await client.hdel(this.ONLINE_EXPERTS_KEY, expertId); + repaired.push({ expertId, fixes }); + continue; + } + + // --- Normalize sessionIds from canonical set if exists --- + const setSessionIds = await this.getExpertSessionIdsFromSet(expertId); + if (setSessionIds.length) { + // CRITICAL: Filter out closed sessions before syncing + // This prevents stale/closed sessions from being added back to onlineExperts + const activeSessionIds = await this.filterActiveSessions(setSessionIds); + expert.sessionIds = [...new Set(activeSessionIds)]; + expert.activeSessions = expert.sessionIds.length; + + // If we filtered out closed sessions, remove them from the SET as well + if (activeSessionIds.length < setSessionIds.length) { + const closedSessionIds = setSessionIds.filter(id => !activeSessionIds.includes(id)); + for (const closedSessionId of closedSessionIds) { + try { + await this.removeSessionFromExpertSet(expertId, closedSessionId); + this.logger.log(`[repairRedisConsistency] Removed closed session ${closedSessionId} from expert ${expertId} SET`); + } catch (err) { + this.logger.warn(`[repairRedisConsistency] Failed to remove closed session ${closedSessionId} from SET`, err as any); + } + } + fixes.push(`Synced from SET, removed ${closedSessionIds.length} closed session(s)`); + } else { + fixes.push('Synced from SET'); + } + } else { + // Fallback to existing array with normalization + expert.sessionIds = Array.isArray(expert.sessionIds) + ? expert.sessionIds.filter(Boolean) + : []; + const before = expert.sessionIds.length; + expert.sessionIds = [...new Set(expert.sessionIds)]; + const after = expert.sessionIds.length; + if (before !== after) fixes.push(`Removed ${before - after} duplicate sessionIds`); + } + + // --- Optionally remove stale sessions --- + if (cleanStale) { + const beforeClean = expert.sessionIds.length; + expert.sessionIds = expert.sessionIds.filter((sid) => + existingSessionIds.has(sid), + ); + const afterClean = expert.sessionIds.length; + if (beforeClean !== afterClean) + fixes.push(`Removed ${beforeClean - afterClean} stale sessionIds`); + } + + // --- Recalculate activeSessions --- + const oldCount = expert.activeSessions; + expert.activeSessions = expert.sessionIds.length; + if (oldCount !== expert.activeSessions) + fixes.push(`Synced activeSessions from ${oldCount} → ${expert.activeSessions}`); + + // --- Ensure valid values --- + if (expert.activeSessions < 0) { + expert.activeSessions = 0; + fixes.push('Fixed negative activeSessions'); + } + + if (expert.maxSessions < expert.activeSessions) { + expert.maxSessions = Math.max(expert.activeSessions, expert.maxSessions); + fixes.push('Expanded maxSessions to match activeSessions'); + } + + // --- Write back only if something changed --- + if (fixes.length > 0) { + this.logger.warn(`[repairRedisConsistency] Writing onlineExperts[${expertId}] fixes: ${fixes.join(', ')}`); + await client.hset(this.ONLINE_EXPERTS_KEY, expertId, JSON.stringify(expert)); + repaired.push({ expertId, fixes }); + } + } + + // --- Reporting --- + if (repaired.length === 0 && orphanedSessions.length === 0) { + this.logger.log('repairRedisConsistency: all entries are consistent ✅'); + } else { + if (repaired.length > 0) { + this.logger.warn( + `repairRedisConsistency: fixed ${repaired.length} experts:\n` + + repaired + .map( + (r) => + ` - ${r.expertId}: ${r.fixes.join(', ')}`, + ) + .join('\n'), + ); + } + if (orphanedSessions.length > 0) { + this.logger.warn(`repairRedisConsistency: found ${orphanedSessions.length} orphaned sessions`); + } + } + + const result: any = repaired.length > 0 ? repaired : undefined; + if (orphanedSessions.length > 0) { + return { ...(result || {}), orphanedSessions: orphanedSessions.length, orphanedSessionIds: orphanedSessions }; + } + return result; + } catch (err) { + this.logger.error('repairRedisConsistency error:', err as any); + throw err; + } +} + +/** + * Detects and fixes duplicate sessions across expert SETs + * A session should only be in one expert's SET at a time + * Uses room data to determine the correct expert assignment + */ +async fixDuplicateSessionsAcrossExperts(): Promise> { + try { + const client = this.client(); + const fixes: Array<{ sessionId: string; removedFrom: string[]; keptIn: string }> = []; + + // Get all experts + const expertsRaw = await client.hgetall(this.ONLINE_EXPERTS_KEY); + if (!expertsRaw || Object.keys(expertsRaw).length === 0) { + return fixes; + } + + // Get all rooms to determine correct expert assignments + const roomsRaw = await client.hgetall(this.ROOMS_HASH_KEY); + const sessionToExpertMap = new Map(); + + // Build map of sessionId -> expertId from room data (source of truth) + for (const [roomKey, roomRaw] of Object.entries(roomsRaw || {})) { + try { + const room = JSON.parse(roomRaw as string) as RoomRedisData; + if (room.sessionId && room.expertId) { + sessionToExpertMap.set(room.sessionId, room.expertId); + } + } catch (err) { + // Skip corrupted room data + } + } + + // Track which sessions are in which expert SETs + const sessionToExpertsMap = new Map>(); + + // Check all expert SETs + for (const [expertId] of Object.entries(expertsRaw)) { + try { + const setKey = this.expertSessionsKey(expertId); + const sessionIds = await client.smembers(setKey); + + for (const sessionId of sessionIds) { + if (!sessionToExpertsMap.has(sessionId)) { + sessionToExpertsMap.set(sessionId, new Set()); + } + sessionToExpertsMap.get(sessionId)!.add(expertId); + } + } catch (err) { + this.logger.warn(`Failed to check expert ${expertId} SET for duplicates`, err as any); + } + } + + // Find sessions in multiple expert SETs + for (const [sessionId, expertIds] of sessionToExpertsMap.entries()) { + if (expertIds.size > 1) { + // Session is in multiple expert SETs - need to fix + const expertIdsArray = Array.from(expertIds); + + // Determine correct expert from room data (source of truth) + const correctExpertId = sessionToExpertMap.get(sessionId); + + let expertToKeep: string; + if (correctExpertId && expertIds.has(correctExpertId)) { + // Room says this expert should have it, and it's in their SET - perfect match + expertToKeep = correctExpertId; + } else if (correctExpertId) { + // Room says a different expert should have it, but it's not in their SET + // This means the room and SET are out of sync - fix by using room as source of truth + expertToKeep = correctExpertId; + try { + // Add to correct expert's SET (even if they're not in onlineExperts hash - SET is canonical) + const setKey = this.expertSessionsKey(correctExpertId); + await client.sadd(setKey, sessionId); + // Try to sync hash if expert is online + const expertExists = expertsRaw[correctExpertId]; + if (expertExists) { + await this.updateOnlineExpertHash(correctExpertId, { includeSessionIdsFromSet: true, isOnline: true }); + } + this.logger.log(`[fixDuplicateSessionsAcrossExperts] Added session ${sessionId} to correct expert ${correctExpertId} SET (was missing)`); + } catch (err) { + this.logger.warn(`Failed to add session ${sessionId} to correct expert ${correctExpertId} SET`, err as any); + // Fallback: keep first expert if we can't add to correct one + expertToKeep = expertIdsArray[0]; + this.logger.warn(`[fixDuplicateSessionsAcrossExperts] Fallback: keeping session ${sessionId} in expert ${expertToKeep} SET`); + } + } else { + // No room data - keep the first expert (arbitrary choice) + expertToKeep = expertIdsArray[0]; + this.logger.warn(`[fixDuplicateSessionsAcrossExperts] Session ${sessionId} has no room data, keeping in expert ${expertToKeep} SET`); + } + + // Remove from all other expert SETs + const removedFrom: string[] = []; + for (const expertId of expertIdsArray) { + if (expertId !== expertToKeep) { + try { + const setKey = this.expertSessionsKey(expertId); + await client.srem(setKey, sessionId); + removedFrom.push(expertId); + + // Sync the expert's hash after removal + await this.updateOnlineExpertHash(expertId, { includeSessionIdsFromSet: true, isOnline: true }); + } catch (err) { + this.logger.warn(`Failed to remove session ${sessionId} from expert ${expertId} SET`, err as any); + } + } + } + + // Sync the expert we kept it in + try { + await this.updateOnlineExpertHash(expertToKeep, { includeSessionIdsFromSet: true, isOnline: true }); + } catch (err) { + this.logger.warn(`Failed to sync expert ${expertToKeep} hash after duplicate fix`, err as any); + } + + fixes.push({ + sessionId, + removedFrom, + keptIn: expertToKeep, + }); + + this.logger.warn( + `[fixDuplicateSessionsAcrossExperts] Fixed duplicate session ${sessionId}: ` + + `removed from experts [${removedFrom.join(', ')}], kept in expert ${expertToKeep}` + ); + } + } + + if (fixes.length > 0) { + this.logger.warn(`[fixDuplicateSessionsAcrossExperts] Fixed ${fixes.length} duplicate sessions across experts`); + } else { + this.logger.log('[fixDuplicateSessionsAcrossExperts] No duplicate sessions found'); + } + + return fixes; + } catch (err) { + this.logger.error('fixDuplicateSessionsAcrossExperts error:', err as any); + throw err; + } +} + + // ============================= + // ===== CHAT TRANSFER ========= + // ============================= + + private async loadOnlineExpertsParsed(): Promise { + const client = this.client(); + const entries = await client.hgetall(this.ONLINE_EXPERTS_KEY); + if (!entries || Object.keys(entries).length === 0) return []; + return Object.values(entries) + .map((v) => { + try { + return JSON.parse(v as string) as ExpertRedisData; + } catch { + return null as any; + } + }) + .filter(Boolean); + } + + private async validateTransferSession( + sessionId: string, + fromExpertId: string, + ): Promise<{ + session: { userId: Types.ObjectId; transferCount?: number; onlineChatClosed?: boolean; chatClosed?: boolean }; + room: RoomRedisData; + userId: string; + }> { + if (!Types.ObjectId.isValid(sessionId) || !Types.ObjectId.isValid(fromExpertId)) { + throw new BadRequestException('Invalid sessionId or expertId'); + } + + const session = await this.sessionModel + .findById(sessionId) + .select('userId transferCount onlineChatClosed chatClosed connectedToExpert') + .lean(); + if (!session) { + throw new BadRequestException('Session not found'); + } + if ((session as any).onlineChatClosed || (session as any).chatClosed) { + throw new BadRequestException('Chat session is closed'); + } + if ((session as any).transferCount >= 1) { + throw new BadRequestException('This conversation has already been transferred once'); + } + + const client = this.client(); + const roomKey = `room:${sessionId}`; + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (!roomRaw) { + throw new BadRequestException('Active chat room not found'); + } + const room = JSON.parse(roomRaw) as RoomRedisData; + if (!room.expertId || String(room.expertId) !== String(fromExpertId)) { + throw new ForbiddenException('You are not the assigned expert for this session'); + } + if (room.isActive === false) { + throw new BadRequestException('Chat room is not active'); + } + + return { + session: session as any, + room, + userId: String((session as any).userId), + }; + } + + async getTransferCandidates( + sessionId: string, + fromExpertId: string, + ): Promise { + await this.validateTransferSession(sessionId, fromExpertId); + + const experts = await this.loadOnlineExpertsParsed(); + const others = experts.filter((e) => e.isOnline && e.expertId !== fromExpertId); + + if (others.length === 0) { + return []; + } + + const result: TransferCandidateDto[] = []; + for (const e of others) { + const max = e.maxSessions || this.MAX_SESSIONS_PER_EXPERT; + const active = e.activeSessions || 0; + const admin = await this.adminModel + .findById(e.expertId) + .select('name family') + .lean(); + result.push({ + expertId: e.expertId, + name: admin?.name ?? '', + family: admin?.family ?? '', + capacityLeft: Math.max(0, max - active), + activeSessions: active, + maxSessions: max, + }); + } + result.sort((a, b) => b.capacityLeft - a.capacityLeft); + return result; + } + + private async applySessionTransfer( + sessionId: string, + userId: string, + fromExpertId: string, + toExpertId: string, + options: { + mode: 'selective' | 'random'; + reason?: string; + source: 'manual' | 'auto'; + incrementTransferCount: boolean; + }, + ): Promise<{ toExpertSocketId: string | null }> { + const client = this.client(); + const roomKey = `room:${sessionId}`; + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (!roomRaw) { + throw new BadRequestException('Room not found during transfer'); + } + + const experts = await this.loadOnlineExpertsParsed(); + const expertsWithSession: string[] = []; + for (const expert of experts) { + if (expert.expertId === toExpertId) continue; + try { + const setKey = this.expertSessionsKey(expert.expertId); + if (await client.sismember(setKey, sessionId)) { + expertsWithSession.push(expert.expertId); + } + } catch { + /* ignore */ + } + } + + const expertsToSync: string[] = []; + for (const expertId of expertsWithSession) { + try { + await this.removeSessionFromExpertSet(expertId, sessionId); + expertsToSync.push(expertId); + } catch (err) { + this.logger.warn(`[applySessionTransfer] SREM failed for ${expertId}`, err as any); + } + } + + if (fromExpertId !== toExpertId) { + try { + await this.removeSessionFromExpertSet(fromExpertId, sessionId); + if (!expertsToSync.includes(fromExpertId)) { + expertsToSync.push(fromExpertId); + } + } catch (err) { + this.logger.warn(`[applySessionTransfer] SREM fromExpert failed`, err as any); + } + } + + try { + const newSetKey = this.expertSessionsKey(toExpertId); + const already = await client.sismember(newSetKey, sessionId); + if (!already) { + await client.sadd(newSetKey, sessionId); + } + } catch (err) { + this.logger.error(`[applySessionTransfer] SADD to ${toExpertId} failed`, err as any); + throw err; + } + + await this.updateOnlineExpertHash(toExpertId, { + includeSessionIdsFromSet: true, + isOnline: true, + }); + for (const expertId of expertsToSync) { + try { + await this.updateOnlineExpertHash(expertId, { includeSessionIdsFromSet: true, isOnline: true }); + } catch (err) { + this.logger.warn(`[applySessionTransfer] hash sync failed for ${expertId}`, err as any); + } + } + + const room = JSON.parse(roomRaw) as RoomRedisData; + room.expertId = toExpertId; + room.isActive = true; + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + + const toExpertEmail = + (await this.adminModel.findById(toExpertId).select('email').lean())?.email || + String(toExpertId); + const fromExpertEmail = + (await this.adminModel.findById(fromExpertId).select('email').lean())?.email || + String(fromExpertId); + + const mongoUpdate: Record = { + expert: toExpertEmail, + connectedToExpert: true, + onlineChatClosed: false, + }; + if (options.incrementTransferCount) { + mongoUpdate.transferCount = 1; + } + await this.sessionModel.updateOne({ _id: sessionId }, { $set: mongoUpdate }); + + await this.logReassignment(sessionId, userId, fromExpertEmail, toExpertEmail, { + source: options.source, + mode: options.mode, + reason: options.reason, + }); + + const pendingSeconds = Number(process.env.PENDING_EXPERT_SECONDS || '300'); + await client.set( + `session:${sessionId}:pendingExpertUntil`, + (Date.now() + pendingSeconds * 1000).toString(), + 'EX', + 60 * 60, + ); + await client.set(`session:${sessionId}:expertSeen`, 'false', 'EX', 60 * 60 * 24); + await client.del(`session:${sessionId}:onlyOneExpertSkipped`); + await this.cancelPendingExpertTimer(sessionId); + await this.refreshInactiveChatTimer(sessionId, false); + await client.set( + `session:${sessionId}:lastActivity`, + Date.now().toString(), + 'EX', + 60 * 60 * 24, + ); + + await this.removeUserFromQueue(userId); + await this.removeWaitingUser(sessionId); + + const toExpertSocketId = await this.getExpertSocketIdFromRedis(toExpertId); + return { toExpertSocketId }; + } + + private async queueSessionForTransfer( + sessionId: string, + userId: string, + fromExpertId: string, + preferredExpertId: string, + mode: 'selective' | 'random', + reason?: string, + ): Promise { + const client = this.client(); + const roomKey = `room:${sessionId}`; + + await this.removeSessionFromExpertSet(fromExpertId, sessionId); + const expertRaw = await client.hget(this.ONLINE_EXPERTS_KEY, fromExpertId); + if (expertRaw) { + await this.updateOnlineExpertHash(fromExpertId, { + includeSessionIdsFromSet: true, + isOnline: true, + }); + } + + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (roomRaw) { + const room = JSON.parse(roomRaw) as RoomRedisData; + room.expertId = null; + await client.hset(this.ROOMS_HASH_KEY, roomKey, JSON.stringify(room)); + } + + await this.sessionModel.updateOne( + { _id: sessionId }, + { + $unset: { expert: '' }, + $set: { connectedToExpert: false, transferCount: 1 }, + }, + ); + + const fromExpertEmail = + (await this.adminModel.findById(fromExpertId).select('email').lean())?.email || + String(fromExpertId); + const toExpertEmail = + (await this.adminModel.findById(preferredExpertId).select('email').lean())?.email || + String(preferredExpertId); + await this.logReassignment(sessionId, userId, fromExpertEmail, toExpertEmail, { + source: 'manual', + mode, + reason: reason ? `${reason} (queued)` : 'queued', + }); + + await client.del(`session:${sessionId}:pendingExpertUntil`); + await client.del(`session:${sessionId}:expertSeen`); + + await this.enqueueUser(userId, sessionId, { + preferredExpertId, + source: 'transfer', + }); + await this.addWaitingUser(userId, sessionId); + + return this.getWaitingPosition(userId); + } + + async transferChat(params: { + sessionId: string; + fromExpertId: string; + mode: 'selective' | 'random'; + targetExpertId?: string; + reason?: string; + }): Promise { + const { sessionId, fromExpertId, mode, targetExpertId, reason } = params; + const { userId } = await this.validateTransferSession(sessionId, fromExpertId); + + const experts = await this.loadOnlineExpertsParsed(); + const otherOnline = experts.filter((e) => e.isOnline && e.expertId !== fromExpertId); + if (otherOnline.length === 0) { + throw new BadRequestException('No other experts are online. Transfer is not available.'); + } + + let targetId: string; + + if (mode === 'selective') { + if (!targetExpertId) { + throw new BadRequestException('targetExpertId is required for selective transfer'); + } + if (targetExpertId === fromExpertId) { + throw new BadRequestException('Cannot transfer to yourself'); + } + const candidates = await this.getTransferCandidates(sessionId, fromExpertId); + const match = candidates.find((c) => c.expertId === targetExpertId); + if (!match) { + const online = otherOnline.find((e) => e.expertId === targetExpertId); + if (!online) { + throw new BadRequestException('Selected expert is not online'); + } + const position = await this.queueSessionForTransfer( + sessionId, + userId, + fromExpertId, + targetExpertId, + mode, + reason, + ); + return { + status: 'queued', + sessionId, + roomId: `room:${sessionId}`, + userId, + fromExpertId, + preferredExpertId: targetExpertId, + position, + mode, + reason, + }; + } + targetId = targetExpertId; + } else { + const available = otherOnline.filter( + (e) => + (e.activeSessions || 0) < (e.maxSessions || this.MAX_SESSIONS_PER_EXPERT), + ); + if (!available.length) { + throw new BadRequestException( + 'All other experts are at capacity. Try selective transfer to queue for a specific expert.', + ); + } + available.sort((a, b) => (a.activeSessions || 0) - (b.activeSessions || 0)); + targetId = available[0].expertId; + } + + const targetExpert = otherOnline.find((e) => e.expertId === targetId); + const atCapacity = + targetExpert && + (targetExpert.activeSessions || 0) >= + (targetExpert.maxSessions || this.MAX_SESSIONS_PER_EXPERT); + + if (atCapacity) { + const position = await this.queueSessionForTransfer( + sessionId, + userId, + fromExpertId, + targetId, + mode, + reason, + ); + return { + status: 'queued', + sessionId, + roomId: `room:${sessionId}`, + userId, + fromExpertId, + preferredExpertId: targetId, + position, + mode, + reason, + }; + } + + const { toExpertSocketId } = await this.applySessionTransfer( + sessionId, + userId, + fromExpertId, + targetId, + { + mode, + reason, + source: 'manual', + incrementTransferCount: true, + }, + ); + + return { + status: 'transferred', + sessionId, + roomId: `room:${sessionId}`, + userId, + fromExpertId, + toExpertId: targetId, + toExpertSocketId, + mode, + reason, + }; + } + + /** + * After expert socket disconnect grace period, auto-reassign their active sessions. + * Does not increment transferCount (not a manual transfer). + */ + async checkAndReassignDisconnectedExperts(): Promise< + Array<{ + sessionId: string; + oldExpertId: string; + newExpertId: string | null; + userId: string; + }> + > { + const client = this.client(); + const thresholdMs = + Number(process.env.EXPERT_INACTIVE_OFFLINE_SECONDS || '900') * 1000; + const now = Date.now(); + const results: Array<{ + sessionId: string; + oldExpertId: string; + newExpertId: string | null; + userId: string; + }> = []; + + const experts = await this.loadOnlineExpertsParsed(); + for (const expert of experts) { + const expertId = expert.expertId; + const disconnectedRaw = await client.get(`expert:${expertId}:disconnectedAt`); + if (!disconnectedRaw) continue; + + const disconnectedAt = parseInt(disconnectedRaw, 10); + if (!disconnectedAt || now - disconnectedAt < thresholdMs) { + continue; + } + + const sessionIds = await this.filterActiveSessions( + await this.getExpertSessionIdsFromSet(expertId), + ); + + for (const sessionId of sessionIds) { + try { + const session = await this.sessionModel + .findById(sessionId) + .select('userId onlineChatClosed chatClosed') + .lean(); + if (!session || (session as any).onlineChatClosed || (session as any).chatClosed) { + continue; + } + const userId = String((session as any).userId); + const roomKey = `room:${sessionId}`; + const roomRaw = await client.hget(this.ROOMS_HASH_KEY, roomKey); + if (!roomRaw) continue; + + const allExperts = await this.loadOnlineExpertsParsed(); + const available = allExperts.filter( + (e) => + e.isOnline && + e.expertId !== expertId && + (e.activeSessions || 0) < + (e.maxSessions || this.MAX_SESSIONS_PER_EXPERT), + ); + + if (!available.length) { + this.logger.warn( + `[checkAndReassignDisconnectedExperts] No expert with capacity for session ${sessionId}`, + ); + continue; + } + + available.sort((a, b) => (a.activeSessions || 0) - (b.activeSessions || 0)); + const newExpert = available[0]; + + await this.applySessionTransfer(sessionId, userId, expertId, newExpert.expertId, { + mode: 'random', + reason: 'expert_disconnect', + source: 'auto', + incrementTransferCount: false, + }); + + results.push({ + sessionId, + oldExpertId: expertId, + newExpertId: newExpert.expertId, + userId, + }); + } catch (err) { + this.logger.warn( + `[checkAndReassignDisconnectedExperts] session ${sessionId} failed`, + err as any, + ); + } + } + + const remaining = await this.filterActiveSessions( + await this.getExpertSessionIdsFromSet(expertId), + ); + if (remaining.length === 0) { + await client.del(`expert:${expertId}:disconnectedAt`); + await this.setExpertOffline(expertId); + } + } + + return results; + } + +async startPendingExpertTimer(sessionId: string, expertId: string) { + const r = this.client(); + await r.set(`${this.PENDING_PREFIX}${sessionId}`, expertId, 'EX', 300); +} + +async refreshInactiveChatTimer(sessionId: string, isExpertMessage: boolean = false) { + const r = this.client(); + const result = await r.set(`${this.INACTIVE_PREFIX}${sessionId}`, sessionId, 'EX', 300); + // Also update the scheduler-observed lastActivity timestamp + await r.set( + `session:${sessionId}:lastActivity`, + Date.now().toString(), + 'EX', + 60 * 60 * 24, + ); + + // If expert sent a message, clear the "only one expert skipped" marker + // This prevents false "didn't answer" logs when expert actually responded + if (isExpertMessage) { + try { + const markerKey = `session:${sessionId}:onlyOneExpertSkipped`; + const markerExists = await r.exists(markerKey); + if (markerExists) { + await r.del(markerKey); + this.logger.log( + `[refreshInactiveChatTimer] Cleared onlyOneExpertSkipped marker for session ${sessionId} because expert sent a message`, + ); + } + } catch (err) { + // Non-critical - log but don't fail + this.logger.warn(`[refreshInactiveChatTimer] Failed to clear onlyOneExpertSkipped marker for session ${sessionId}`, err as any); + } + } + + console.log('refreshInactiveChatTimer: refreshed timer and lastActivity for session', sessionId, result); +} + +async handlePendingExpertExpiry(sessionId: string) { + const r = this.client(); + const expertId = await r.get(`${this.PENDING_PREFIX}${sessionId}`); + if (!expertId) return null; + + this.logger.warn(`Expert ${expertId} never joined ${sessionId}`); + + await this.endChat(sessionId); + // For reassignment, we don't know business hours status, so pass false + // This will queue if no experts available (old behavior for reassignment) + const newExpert = await this.assignExpert({ userId: '', sessionId }, false); + if (newExpert.status !== 'assigned') return null; + + await this.startPendingExpertTimer(sessionId, newExpert.expertId); + return { + event: 'expertReassigned', + data: { sessionId, newExpertId: newExpert.expertId }, + }; +} + +async handleInactiveChatExpiry(sessionId: string) { + await this.endChat(sessionId); + return { event: 'chatAutoEnded', data: { sessionId } }; +} + +async cancelPendingExpertTimer(sessionId: string) { + const client = this.client(); + // remove any legacy keys and the active pending key + await client.del(`session:${sessionId}:pendingExpertUntil`); + await client.del(`session:${sessionId}:pendingExpertFor`); + await client.del(`${this.PENDING_PREFIX}${sessionId}`); + return true; +} + +// async setupRedisExpiryListener(onEvent: (evt: any) => void) { +// const c = this.client(); +// await c.configSet('notify-keyspace-events', 'Ex'); +// const sub = c.duplicate(); +// await sub.connect(); +// await sub.psubscribe('__keyevent@0__:expired', async (key: string) => { +// if (key.startsWith(this.PENDING_PREFIX)) { +// const id = key.split(':')[1]; +// onEvent(await this.handlePendingExpertExpiry(id)); +// } else if (key.startsWith(this.INACTIVE_PREFIX)) { +// const id = key.split(':')[1]; +// onEvent(await this.handleInactiveChatExpiry(id)); +// } +// }); +// } + + // ============================= + // ===== CLEANUP FUNCTIONS ===== + // ============================= + + /** + * Cleans up junk rooms with invalid userId or expertId + * This function is safe to run periodically and won't interrupt other operations + */ + async cleanupJunkRooms(): Promise<{ removed: number; errors: number; details: string[] }> { + const client = this.client(); + const stats = { removed: 0, errors: 0, details: [] }; + + try { + this.logger.log('Starting junk rooms cleanup...'); + const roomsRaw = await client.hgetall(this.ROOMS_HASH_KEY); + const totalRooms = Object.keys(roomsRaw).length; + this.logger.log(`Found ${totalRooms} rooms to check`); + + for (const [roomKey, raw] of Object.entries(roomsRaw)) { + try { + const room = JSON.parse(raw) as RoomRedisData; + const sessionId = String(room.sessionId); + let shouldRemove = false; + let reason = ''; + + // Check 1: Invalid userId + if (!room.userId || room.userId === 'unknown-user') { + shouldRemove = true; + reason = 'invalid userId (null or unknown-user)'; + } + + // Check 2: Validate userId exists + if (!shouldRemove && room.userId) { + const userExists = await this.userModel.exists({ _id: room.userId }); + if (!userExists) { + shouldRemove = true; + reason = `userId does not exist: ${room.userId}`; + } + } + + // Check 3: expertId is null but session says expert is connected + if (!shouldRemove && room.expertId === null && room.isActive) { + try { + const session = await this.sessionModel + .findById(sessionId) + .select('expert connectedToExpert') + .lean(); + if (session?.connectedToExpert && !session?.expert) { + // Session says expert is connected but expertId is null - invalid state + shouldRemove = true; + reason = 'expertId null but connectedToExpert is true'; + } + } catch (err) { + // If session doesn't exist, we'll catch it in Check 4 + } + } + + // Check 4: Validate expertId if present + if (!shouldRemove && room.expertId) { + // Try to normalize (handles email/username) + const normalized = await this.normalizeExpertId(room.expertId, sessionId); + if (!normalized) { + shouldRemove = true; + reason = `expertId does not exist or invalid: ${room.expertId}`; + } + } + + // Check 5: Validate session exists and is active + if (!shouldRemove) { + const session = await this.sessionModel.findById(sessionId).select('_id chatClosed onlineChatClosed').lean(); + if (!session) { + shouldRemove = true; + reason = 'session does not exist in MongoDB'; + } else if ((session as any).chatClosed || (session as any).onlineChatClosed) { + shouldRemove = true; + reason = 'session is marked as closed in MongoDB'; + } + } + + // Check 6: Validate session is assigned to an expert in onlineExperts + // The source of truth for "active session" is whether an expert has it in their list + if (!shouldRemove && room.expertId) { + try { + // We check the expert's SET of sessions (canonical source) + const setKey = this.expertSessionsKey(String(room.expertId)); + const isAssigned = await client.sismember(setKey, sessionId); + + if (!isAssigned) { + shouldRemove = true; + reason = `session ${sessionId} not found in expert ${room.expertId}'s active session set`; + } + } catch (err) { + // If check fails, be conservative and don't remove yet + } + } + + if (shouldRemove) { + this.logger.warn(`Removing junk room ${roomKey} (session: ${sessionId}): ${reason}`); + await client.hdel(this.ROOMS_HASH_KEY, roomKey); + + // Cleanup auxiliary keys (non-blocking) + try { + await Promise.all([ + client.del(`session:${sessionId}:expertSeen`), + client.del(`session:${sessionId}:pendingExpertUntil`), + client.del(`session:${sessionId}:lastActivity`), + ]); + } catch (err) { + // Ignore errors in cleanup + } + + stats.removed++; + stats.details.push(`${roomKey}: ${reason}`); + } + } catch (err) { + this.logger.error(`Error processing room ${roomKey}:`, err); + stats.errors++; + } + } + + this.logger.log( + `Junk rooms cleanup completed: removed ${stats.removed} rooms, ${stats.errors} errors out of ${totalRooms} total rooms` + ); + return stats; + } catch (err) { + this.logger.error('cleanupJunkRooms error', err as any); + throw err; + } + } + +} diff --git a/src/socket/support-management/dto/create-support-management.dto.ts b/src/socket/support-management/dto/create-support-management.dto.ts new file mode 100644 index 0000000..432be80 --- /dev/null +++ b/src/socket/support-management/dto/create-support-management.dto.ts @@ -0,0 +1,8 @@ +import { IsEmail, IsString } from 'class-validator'; + +export class JoinSupportUser { + @IsString() + userId: string; + @IsEmail() + email: string; +} diff --git a/src/socket/support-management/dto/eventPayloads.dto.ts b/src/socket/support-management/dto/eventPayloads.dto.ts new file mode 100644 index 0000000..39fb417 --- /dev/null +++ b/src/socket/support-management/dto/eventPayloads.dto.ts @@ -0,0 +1,294 @@ +import { + IsString, + IsNotEmpty, + IsIn, + IsMongoId, + IsOptional, + Matches, + IsNumber, + Min, + Max, +} from 'class-validator'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { HttpStatus } from '@nestjs/common'; +import { Sender } from 'src/common/types/sender.type'; + +export type UserRole = 'User' | 'Expert' | 'Bot'; + +// ====== COMMON (Validated DTOs) ====== +export class JoinRoomPayload { + @IsOptional() + @IsString() + @Matches(/^room:[0-9a-fA-F]{24}$/) + roomId?: string; + + @IsString() + @IsMongoId() + userId!: string; + + @IsString() + @IsIn(['User', 'Expert', 'Bot']) + role!: UserRole; +} + +export class LeaveRoomPayload { + @IsString() + @IsNotEmpty() + @Matches(/^room:[0-9a-fA-F]{24}$/) + roomId!: string; + + @IsString() + @IsMongoId() + userId!: string; + + @IsString() + @IsIn(['User', 'Expert', 'Bot']) + role!: UserRole; +} + +export class NotificationPayload { + @IsIn(['info', 'success', 'warning', 'error']) + type!: 'info' | 'success' | 'warning' | 'error'; + + @IsString() + @IsNotEmpty() + message!: string; +} + +export class ErrorPayload { + @IsString() + code!: string; + + @IsString() + message!: string; +} + +// ====== ONLINE STATUS ====== +export class ExpertOnlinePayload { + @IsString() + @IsMongoId() + expertId!: string; +} + +export class ExpertStatusUpdatePayload { + experts!: { + id: string; + name: string; + available: boolean; + activeSessions: number; + maxSessions: number; + }[]; +} + +// ====== MATCHING / CONNECTION ====== +export class RequestExpertPayload { + @IsString() + @IsMongoId() + userId!: string; + + @IsString() + @IsMongoId() + sessionId!: string; +} + +export class ExpertAssignedPayload { + expertId!: string; + expertName!: string; + roomId!: string; +} + +export class UserAssignedPayload { + userId!: string; + username!: string; + roomId!: string; +} + +export class NoExpertAvailablePayload { + waitingPosition!: number; +} + +export class WaitingListUpdatePayload { + totalWaiting!: number; +} + +export class SendMessagePayload { + @IsOptional() + @IsString() + @Matches(/^room:[0-9a-fA-F]{24}$/) + roomId?: string; + + @IsString() + @IsMongoId() + sessionId!: string; + + @IsString() + @IsMongoId() + senderId!: string; + + @IsString() + @IsIn(['User', 'Expert', 'Bot']) + senderRole!: UserRole; + + @IsString() + @IsNotEmpty() + message!: string; + + @IsOptional() + @IsIn(['text', 'image', 'voice', 'document']) + type?: 'text' | 'image' | 'voice' | 'document'; + + /** When type is voice: duration in seconds (from upload API or client). Optional but recommended for UI. */ + @IsOptional() + @IsNumber() + @Min(0) + @Max(7200) + voiceDurationSec?: number; + + /** MIME type for voice / image / document messages (optional). */ + @IsOptional() + @IsString() + mimeType?: string; + + /** If set, must reference another message in the same session (online reply / quote). */ + @IsOptional() + @IsString() + @IsMongoId() + replyToMessageId?: string; + + /** + * Object-storage attachments: must match the message id embedded in `chats///...` + * and the row created at upload time (voice/image/document). + */ + @IsOptional() + @IsString() + @IsMongoId() + presetMessageId?: string; +} + +export class ReceiveMessagePayload { + roomId!: string; + senderId!: string; + senderRole!: UserRole; + message!: string; + createdAt!: string; + createdISO!: string; +} + + +// ====== CHAT HISTORY ====== +export class GetChatHistoryPayload { + @IsString() + @IsMongoId() + sessionId!: string; +} + +/** Denormalized quote for replies; `unavailable` is true if the parent message is missing from the session. */ +export interface MessageReplyToSnapshot { + messageId: string; + textPreview: string; + sender: UserRole; + createdISO?: string; + unavailable?: boolean; +} + +export class ChatHistoryPayload { + sessionId!: string; + messages!: { + sender: UserRole; + message: string; + createdAt: string; + messageId?: string; + replyTo?: MessageReplyToSnapshot; + }[]; +} + +export class ActiveChatsPayload { + expertId!: string; + chats!: { + roomId: string; + userId: string; + username: string; + }[]; +} + +export class AssignedExpertPayload { + expertId!: string; + expertName!: string; +} + +// ====== CHAT TRANSFER ====== +export class GetTransferCandidatesPayload { + @IsString() + @IsMongoId() + sessionId!: string; + + @IsString() + @IsMongoId() + expertId!: string; +} + +export class TransferChatPayload { + @IsString() + @IsMongoId() + sessionId!: string; + + @IsString() + @IsMongoId() + expertId!: string; + + @IsString() + @IsIn(['selective', 'random']) + mode!: 'selective' | 'random'; + + @IsOptional() + @IsString() + @IsMongoId() + targetExpertId?: string; + + @IsOptional() + @IsString() + reason?: string; +} + +// ====== SESSION CONTROL ====== +export class EndChatPayload { + @IsString() + @Matches(/^room:[0-9a-fA-F]{24}$/) + roomId!: string; + + @IsString() + @IsMongoId() + sessionId!: string; +} + +export class ChatEndedPayload { + roomId!: string; + reason?: string; +} + +interface ExpertRedisData { + expertId: string; + socketId: string; + activeSessions: number; + maxSessions: number; +} + + +export class SocketResponsePayload extends BaseResponseDTO { + event: string; + + constructor( + event: string, + statusCode: HttpStatus, + message: string, + data: T, + meta?: any, + ) { + super(statusCode, message, data, meta); + this.event = event; + } +} + +export function createSocketResponse(event: string, data: T, message: string = 'Success', statusCode: HttpStatus = HttpStatus.OK, meta?: any): SocketResponsePayload { + return new SocketResponsePayload(event, statusCode, message, data, meta); +} \ No newline at end of file diff --git a/src/socket/support-management/dto/support-events.dto.ts b/src/socket/support-management/dto/support-events.dto.ts new file mode 100644 index 0000000..5a25826 --- /dev/null +++ b/src/socket/support-management/dto/support-events.dto.ts @@ -0,0 +1,63 @@ +import { IsIn, IsMongoId, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class JoinEventDto { + @IsMongoId() + @IsString() + userId!: string; + + @IsString() + @IsIn(['User', 'Expert']) + role!: 'User' | 'Expert'; + + @IsString() + @IsNotEmpty() + name!: string; + + @IsMongoId() + @IsString() + sessionId!: string; +} + +export class SendMessageEventDto { + @IsMongoId() + @IsString() + senderId!: string; + + @IsMongoId() + @IsString() + receiverId!: string; + + @IsString() + @IsNotEmpty() + message!: string; + + @IsMongoId() + @IsString() + sessionId!: string; +} + +export class EditMessageEventDto { + @IsMongoId() + @IsString() + sessionId!: string; + + @IsString() + @IsNotEmpty() + messageId!: string; + + @IsString() + @IsNotEmpty() + newMessage!: string; + + @IsMongoId() + @IsString() + editorId!: string; +} + +export class LeaveEventDto { + @IsMongoId() + @IsString() + userId!: string; +} + + diff --git a/src/socket/support-management/dto/update-support-management.dto.ts b/src/socket/support-management/dto/update-support-management.dto.ts new file mode 100644 index 0000000..e349d20 --- /dev/null +++ b/src/socket/support-management/dto/update-support-management.dto.ts @@ -0,0 +1,3 @@ +export class UpdateSupportManagementDto { + id: number; +} diff --git a/src/socket/support-management/entities/support-management.entity.ts b/src/socket/support-management/entities/support-management.entity.ts new file mode 100644 index 0000000..00add0c --- /dev/null +++ b/src/socket/support-management/entities/support-management.entity.ts @@ -0,0 +1 @@ +export class SupportManagement {} diff --git a/src/socket/support-management/support-management.gateway.spec.ts b/src/socket/support-management/support-management.gateway.spec.ts new file mode 100644 index 0000000..9d1dea0 --- /dev/null +++ b/src/socket/support-management/support-management.gateway.spec.ts @@ -0,0 +1,19 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SupportManagementGateway } from './support-management.gateway'; +import { SupportManagementService } from './support-management.service'; + +describe('SupportManagementGateway', () => { + let gateway: SupportManagementGateway; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SupportManagementGateway, SupportManagementService], + }).compile(); + + gateway = module.get(SupportManagementGateway); + }); + + it('should be defined', () => { + expect(gateway).toBeDefined(); + }); +}); diff --git a/src/socket/support-management/support-management.gateway.ts b/src/socket/support-management/support-management.gateway.ts new file mode 100644 index 0000000..c248bac --- /dev/null +++ b/src/socket/support-management/support-management.gateway.ts @@ -0,0 +1,584 @@ +import { InjectModel } from '@nestjs/mongoose'; +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + OnGatewayConnection, + OnGatewayDisconnect, + MessageBody, + ConnectedSocket, +} from '@nestjs/websockets'; +import { Model, Types } from 'mongoose'; +import { Server, Socket } from 'socket.io'; +import { Sender } from 'src/common/types/sender.type'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { AdminService } from '../../api/admin/admin.service'; +import { ConversationService } from '../../conversation/conversation.service'; +import { UserService } from '../../api/user/user.service'; + +interface User { + id: string; + socketId: string; + role: Sender.Expert | Sender.User; + name: string; + sessionId: string; +} + +interface PendingMessage { + senderId: string; + message: string; + sessionId: string; +} + +@WebSocketGateway({ cors: { origin: '*' } }) +export class SupportManagementGateway + implements OnGatewayDisconnect, OnGatewayConnection { + constructor( + private readonly adminService: AdminService, + private readonly userService: UserService, + @InjectModel(SessionModel.name) + private readonly session: Model, + private readonly conversationService: ConversationService, + ) { } + @WebSocketServer() server: Server; + private connectedIps = new Set(); + + private supports: User[] = []; + private users: User[] = []; + private waitingUsers: User[] = []; + private supportAssignments: Record = {}; + private pendingMessages: Record = {}; + private roundRobinIndex: number = 0; + + handleDisconnect(client: Socket) { + console.log( + `Client disconnected: ${client.id} - ${client.handshake.address}`, + ); + + const disconnectedSupport = this.supports.find( + (support) => support.socketId === client.id, + ); + if (disconnectedSupport) { + const assignedUsers = + this.supportAssignments[disconnectedSupport.id] || []; + assignedUsers.forEach((userId) => { + const user = this.users.find((u) => u.id === userId); + if (user) { + this.waitingUsers.push(user); + this.server.to(user.socketId).emit('support_disconnected', { + message: 'پشتیبان شما از سیستم خارج شد. لطفا منتظر بمانید.', + }); + this.userService.toggleExpertActions( + user.id, + user.sessionId, + 'onlineChatClosed', + ); + } + }); + delete this.supportAssignments[disconnectedSupport.id]; + } + + this.supports = this.supports.filter( + (support) => support.socketId !== client.id, + ); + this.users = this.users.filter((user) => user.socketId !== client.id); + + Object.keys(this.supportAssignments).forEach((supportId) => { + this.supportAssignments[supportId] = this.supportAssignments[ + supportId + ].filter((userId) => userId !== client.id); + if (this.supportAssignments[supportId].length === 0) { + delete this.supportAssignments[supportId]; + } + }); + this.server.emit('update_support_list', this.getSupportList()); + } + + handleConnection(client: Socket) { + console.log(`Client connected: ${client.id} - ${client.handshake.address}`); + } + + @SubscribeMessage('join') + async handleJoin( + @MessageBody() + data: { + userId: string; + role: Sender.User | Sender.Expert; + name: string; + sessionId: string; + }, + @ConnectedSocket() client: Socket, + ) { + const user: User = { + id: data.userId, + socketId: client.id, + role: data.role, + name: data.name, + sessionId: data.sessionId, + }; + + if (data.role === Sender.Expert) { + const adminExists = await this.adminService.findOneAdmin({ + _id: new Types.ObjectId(user.id), + }); + if (adminExists) { + this.supports.push(user); + this.supportAssignments[user.id] = []; + this.assignWaitingUsersToSupport(user); + } else { + this.server.to(user.socketId).emit('application_error', { + message: `${user.name}, ${user.id} - ادمین پیدا نشد`, + }); + return; + } + } else { + try { + if (!Types.ObjectId.isValid(user.id)) { + this.server.to(user.socketId).emit('application_error', { + message: `${user.name}, ${user.id} معتبر نمیباشد`, + }); + return; + } + const currentUser = await this.userService.findOneUser({ + _id: new Types.ObjectId(user.id), + }); + if (currentUser == null) { + this.server.to(user.socketId).emit('application_error', { + message: `${user.name}, ${user.id} - کاربر پیدا نشد`, + }); + return; + } + + this.users.push(user); + if (this.supports.length === 0) { + this.waitingUsers.push(user); + this.server.to(user.socketId).emit('waiting_for_support'); + } else { + this.assignToSupport(user); + } + } catch (err) { + this.server.to(user.socketId).emit('application_error', { err: err }); + return; + } + } + + console.log(`${data.role} joined:`, user); + this.server.emit('update_support_list', this.getSupportList()); + } + + private assignWaitingUsersToSupport(support: User) { + while ( + this.waitingUsers.length > 0 && + this.supportAssignments[support.id].length < 4 + ) { + const user = this.waitingUsers.shift(); + if (user) { + this.supportAssignments[support.id].push(user.id); + + this.userService.toggleExpertActions( + user.id, + user.sessionId, + 'connectedToExpert', + support.id, + ); + + this.server.to(user.socketId).emit('assigned_support', { + supportId: support.id, + name: support.name, + }); + this.server.to(support.socketId).emit('new_user_assigned', { + userId: user.id, + name: user.name, + }); + console.log([`chat.${[user.sessionId]}.expert`]); + this.server.to(user.socketId).emit('assigned_message', { + message: `پشتیبان ${support.name} به شما اختصاص داده شد.`, + }); + + this.deliverPendingMessages(user.id, support.socketId); + } + } + } + + private async assignToSupport(user: User) { + try { + if (this.supports.length === 0) { + this.server.to(user.socketId).emit('no_support_available'); + return; + } + let attempts = 0; + let supportId; + const maxAttempts = this.supports.length; + + while (attempts < maxAttempts) { + const availableSupport = this.supports[this.roundRobinIndex]; + + // Skip if support is not available + if (!availableSupport) { + this.roundRobinIndex = + (this.roundRobinIndex + 1) % this.supports.length; + attempts++; + continue; + } + + // Initialize support assignments array if it doesn't exist + if (!this.supportAssignments[availableSupport.id]) { + this.supportAssignments[availableSupport.id] = []; + } + + // Check if support can take more users + if (this.supportAssignments[availableSupport.id].length < 4) { + try { + // Add user to support's assignments + this.supportAssignments[availableSupport.id].push(user.id); + supportId = availableSupport.id; + + // Notify user + this.server.to(user.socketId).emit('assigned_support', { + supportId: availableSupport.id, + name: availableSupport.name, + }); + + // Notify support + this.server + .to(availableSupport.socketId) + .emit('new_user_assigned', { + userId: user.id, + name: user.name, + }); + + // Send assignment message + this.server.to(user.socketId).emit('assigned_message', { + message: `پشتیبان ${availableSupport.name} به شما اختصاص داده شد.`, + }); + + // Update user's expert connection + await this.userService.toggleExpertActions( + user.id, + user.sessionId, + 'connectedToExpert', + supportId, + ); + + // Deliver any pending messages + this.deliverPendingMessages(user.id, availableSupport.socketId); + + // Update round robin index + this.roundRobinIndex = + (this.roundRobinIndex + 1) % this.supports.length; + return; + } catch (error) { + console.error('Error assigning user to support:', error); + // Remove user from assignments if there was an error + if (supportId) { + this.supportAssignments[supportId] = this.supportAssignments[ + supportId + ].filter((id) => id !== user.id); + } + throw error; + } + } + + // Move to next support + this.roundRobinIndex = + (this.roundRobinIndex + 1) % this.supports.length; + attempts++; + } + + // If we get here, no support was available + this.server.to(user.socketId).emit('no_support_available'); + } catch (error) { + console.error('Error in assignToSupport:', error); + this.server.to(user.socketId).emit('application_error', { + message: 'خطا در اختصاص پشتیبان. لطفا دوباره تلاش کنید.', + }); + } + } + + @SubscribeMessage('send_message') + async handleMessage( + @MessageBody() + data: { + senderId: string; + receiverId: string; + message: string; + sessionId: string; + }, + ) { + const allParticipants = [...this.supports, ...this.users]; + const sender = allParticipants.find((u) => u.id === data.senderId); + const receiver = allParticipants.find((u) => u.id === data.receiverId); + + if (!sender || !receiver) { + console.log('گیرنده یا فرستنده پیدا نشد. ذخیره پیام...'); + this.pendingMessages[data.receiverId] = + this.pendingMessages[data.receiverId] || []; + + this.pendingMessages[data.receiverId].push({ + senderId: data.senderId, + message: data.message, + sessionId: data.sessionId, + }); + return; + } + + const senderRole = + sender.role === Sender.User ? Sender.User : Sender.Expert; + await this.userService.insertNewChatNewStructure( + data.sessionId, + data.message, + senderRole, + Date.now() / 1000, + ); + + const targetUserId = sender.role === Sender.User ? sender.id : receiver.id; + const chatCollection = await this.userService.userSessionHistory( + targetUserId, + data.sessionId, + ); + + const messageData = { + data: chatCollection.data, + senderRole: sender.role, + senderId: sender.id, + senderName: sender.name, + receiverRole: receiver.role, + receiverId: receiver.id, + receiverName: receiver.name, + message: data.message, + }; + + this.server.to(sender.socketId).emit('all_session_message', messageData); + this.server.to(receiver.socketId).emit('all_session_message', messageData); + + this.server.to(receiver.socketId).emit('receive_message', messageData); + } + + @SubscribeMessage('edit_message') + async handleEditMessage( + @MessageBody() + data: { + sessionId: string; + messageId: string; + newMessage: string; + editorId: string; + }, + @ConnectedSocket() client: Socket, + ) { + const editor = [...this.supports, ...this.users].find( + (u) => u.id === data.editorId, + ); + + if (!editor || editor.role !== Sender.Expert) { + this.server.to(client.id).emit('application_error', { + message: 'فقط کارشناسان مجاز به ویرایش پیام هستند.', + }); + return; + } + + try { + const updatedMessage = await this.userService.editChatMessage( + data.sessionId, + data.messageId, + data.newMessage, + ); + + if (updatedMessage) { + // Get the full session document to find the user ID + const sessionDoc = await this.session.findById(data.sessionId).exec(); + if (!sessionDoc) { + console.warn(`Session not found for sessionId: ${data.sessionId}`); + this.server.to(client.id).emit('application_error', { + message: 'Session not found.', + }); + return; + } + + // The expert who edited the message. His socket is 'client.id' or could be found in 'this.supports' using 'data.editorId'. + const expertSocketInfo = this.supports.find((s) => s.id === data.editorId); + + // Find the user (receiver in this session) using the userId from the session document. + const userSocketInfo = this.users.find( + (u) => u.id === sessionDoc.userId.toString(), + ); + + if (expertSocketInfo) { + this.server.to(expertSocketInfo.socketId).emit('message_edited', updatedMessage); + } else { + console.warn(`Expert socket not found for editorId: ${data.editorId}`); + } + + if (userSocketInfo) { + this.server.to(userSocketInfo.socketId).emit('message_edited', updatedMessage); + } else { + console.warn(`User socket not found for userId: ${sessionDoc.userId.toString()}`); + } + } else { + this.server.to(client.id).emit('application_error', { + message: 'پیام برای ویرایش پیدا نشد.', + }); + } + } catch (error) { + console.error('Error editing message:', error); + this.server.to(client.id).emit('application_error', { + message: 'خطا در ویرایش پیام.', + }); + } + } + + @SubscribeMessage('leave') + async handleLeave( + @MessageBody() data: { userId: string }, + @ConnectedSocket() client: Socket, + ) { + const user = [...this.supports, ...this.users].find( + (u) => u.id === data.userId, + ); + console.log(user); + if (user) { + this.server + .to(user.socketId) + .emit('disconnected', { message: 'شما از سیستم خارج شدید' }); + client.to(user.socketId).disconnectSockets(); + if (user.sessionId) { + console.log(user.sessionId); + console.log('here before call the start conversation'); + await this.conversationService.startConversation(user.sessionId, true); + } + } + + this.supports = this.supports.filter( + (support) => support.id !== data.userId, + ); + this.users = this.users.filter((user) => user.id !== data.userId); + + Object.keys(this.supportAssignments).forEach((supportId) => { + this.supportAssignments[supportId] = this.supportAssignments[ + supportId + ].filter((userId) => userId !== data.userId); + if (this.supportAssignments[supportId].length === 0) { + delete this.supportAssignments[supportId]; + } + }); + + this.server.emit('update_support_list', this.getSupportList()); + } + + @SubscribeMessage('getSupportList') + supportLists() { + this.server.emit('getSupportList', this.getSupportList()); + } + + @SubscribeMessage('support_get_user_list') + supportGetUserList(@ConnectedSocket() client: Socket) { + const support = this.supports.find((s) => s.socketId === client.id); + if (!support) + this.server + .to(client.id) + .emit('application_error', { message: 'کارشناس پیدا نشد ' }); + const assignedUserIds = this.supportAssignments[support.id]; + if (!assignedUserIds) return []; + + const assignedUsers = this.users.filter((user) => + assignedUserIds.includes(user.id), + ); + this.server.to(client.id).emit('support_get_user_list', assignedUsers); + return assignedUsers; + } + + @SubscribeMessage('getSocketInfo') + applicationSupport() { + console.log('info', { + supportAssignments: this.supportAssignments, + users: this.users, + supports: this.supports, + }); + } + @SubscribeMessage('getAssignedSupport') + handleGetAssignedSupport(@ConnectedSocket() client: Socket) { + const user = this.users.find((u) => u.socketId === client.id); + if (!user) { + this.server + .to(client.id) + .emit('application_error', { message: 'کاربر پیدا نشد' }); + return; + } + + let assignedSupport: User | undefined; + for (const support of this.supports) { + if (this.supportAssignments[support.id]?.includes(user.id)) { + assignedSupport = support; + break; + } + } + + if (assignedSupport) { + this.server.to(client.id).emit('assigned_support_info', { + supportId: assignedSupport.id, + + name: assignedSupport.name, + }); + } else { + this.server.to(client.id).emit('no_assigned_support', { + message: 'هیچ پشتیبانی به شما اختصاص داده نشده است', + }); + } + } + + @SubscribeMessage('get_assigned_users_info') + async handleGetAssignedUsersInfo(@ConnectedSocket() client: Socket) { + const support = this.supports.find((s) => s.socketId === client.id); + if (!support) { + this.server + .to(client.id) + .emit('application_error', { message: 'پشتیبان پیدا نشد' }); + return; + } + + const assignedUserIds = this.supportAssignments[support.id] || []; + const assignedUsers = this.users.filter((user) => + assignedUserIds.includes(user.id), + ); + + const usersWithSessionInfo = await Promise.all( + assignedUsers.map(async (user) => { + let sessionHistory = []; + try { + sessionHistory = await this.session.find({ + userId: new Types.ObjectId(user.id), + _id: user.sessionId && Types.ObjectId.isValid(user.sessionId) ? new Types.ObjectId(user.sessionId) : null, + }); + } catch (error) { + console.error(`Error fetching session history for user ${user.id}:`, error); + // Optionally, you might want to return a default or partial object here + } + return { + userId: user.id, + name: user.name, + sessionId: user.sessionId, + sessionHistory: sessionHistory, + }; + }), + ); + this.server.to(client.id).emit('assigned_users_info', usersWithSessionInfo); + } + + private getSupportList() { + return this.supports.map((support) => ({ + id: support.id, + name: support.name, + assignedUsers: this.supportAssignments[support.id] || [], + })); + } + + private deliverPendingMessages(userId: string, supportSocketId: string) { + const messages = this.pendingMessages[userId] || []; + messages.forEach((msg) => { + this.server.to(supportSocketId).emit('receive_message', { + senderName: msg.senderId, + message: msg.message, + }); + }); + delete this.pendingMessages[userId]; + } +} diff --git a/src/socket/support-management/support-management.module.ts b/src/socket/support-management/support-management.module.ts new file mode 100644 index 0000000..346205d --- /dev/null +++ b/src/socket/support-management/support-management.module.ts @@ -0,0 +1,16 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { SupportManagementService } from './support-management.service'; +import { ChatGateway } from './chat.gateway'; +import { ChatService } from './chat.service'; +import { UserModule } from '../../api/user/user.module'; +import { ConversationModule } from '../../conversation/conversation.module'; +import { DatabaseModule } from 'src/database/database.module'; +import { BusinessHoursModule } from 'src/business-hours/business-hours.module'; +import { AdminModule } from '../../api/admin/admin.module'; + +@Module({ + imports: [DatabaseModule, UserModule, ConversationModule, BusinessHoursModule, forwardRef(() => AdminModule)], + providers: [ChatGateway, SupportManagementService, ChatService], + exports: [ChatService], +}) +export class SupportManagementModule {} diff --git a/src/socket/support-management/support-management.service.spec.ts b/src/socket/support-management/support-management.service.spec.ts new file mode 100644 index 0000000..17f3576 --- /dev/null +++ b/src/socket/support-management/support-management.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SupportManagementService } from './support-management.service'; + +describe('SupportManagementService', () => { + let service: SupportManagementService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SupportManagementService], + }).compile(); + + service = module.get(SupportManagementService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/socket/support-management/support-management.service.ts b/src/socket/support-management/support-management.service.ts new file mode 100644 index 0000000..f59831e --- /dev/null +++ b/src/socket/support-management/support-management.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { UpdateSupportManagementDto } from './dto/update-support-management.dto'; +import { AdminService } from '../../api/admin/admin.service'; + +@Injectable() +export class SupportManagementService { + constructor(private readonly adminService: AdminService) {} + + findAll() { + return `This action returns all supportManagement`; + } + + findOne(id: number) { + return `This action returns a #${id} supportManagement`; + } + + update(id: number, updateSupportManagementDto: UpdateSupportManagementDto) { + return `This action updates a #${id} supportManagement`; + } + + remove(id: number) { + return `This action removes a #${id} supportManagement`; + } +} diff --git a/src/storage/key-builder.helper.ts b/src/storage/key-builder.helper.ts new file mode 100644 index 0000000..7b0b960 --- /dev/null +++ b/src/storage/key-builder.helper.ts @@ -0,0 +1,68 @@ +import { BadRequestException } from '@nestjs/common'; +import { Types } from 'mongoose'; +import type { ChatAttachmentFileType } from './storage.types'; +import { MIME_TO_EXT } from './storage.constants'; + +const OBJECT_ID_HEX = /^[a-fA-F0-9]{24}$/; +const CATEGORY_SAFE = /^[a-zA-Z0-9][a-zA-Z0-9\-_]{0,63}$/; + +export function sanitizeCategorySegment(category: string): string { + const c = (category ?? '').trim(); + if (!c || !CATEGORY_SAFE.test(c)) { + throw new BadRequestException('invalid_dictionary_category'); + } + return c; +} + +export function assertValidObjectIds(...ids: string[]): void { + for (const id of ids) { + if (!id || !OBJECT_ID_HEX.test(id) || !Types.ObjectId.isValid(id)) { + throw new BadRequestException('invalid_object_id'); + } + } +} + +/** + * Object key inside the private bucket (no bucket name prefix). + * Example: dictionaries/lifeInsurance/1746941200.csv + */ +export function buildDictionaryStorageKey(category: string, extWithDot: string): string { + const safeCat = sanitizeCategorySegment(category); + const tsSec = Math.floor(Date.now() / 1000); + const ext = normalizeExt(extWithDot); + return `dictionaries/${safeCat}/${tsSec}${ext}`; +} + +/** + * Example: chats///voice/1746941400.mp3 + */ +export function buildChatAttachmentStorageKey( + sessionId: string, + messageId: string, + fileType: ChatAttachmentFileType, + extWithDot: string, +): string { + assertValidObjectIds(sessionId, messageId); + const tsSec = Math.floor(Date.now() / 1000); + const ext = normalizeExt(extWithDot); + return `chats/${sessionId}/${messageId}/${fileType}/${tsSec}${ext}`; +} + +export function normalizeExt(extWithDot: string): string { + let e = (extWithDot || '').trim().toLowerCase(); + if (!e) return ''; + if (!e.startsWith('.')) e = `.${e}`; + if (!/^\.[a-z0-9]{1,10}$/.test(e)) { + throw new BadRequestException('invalid_storage_extension'); + } + return e; +} + +/** Infer extension from MIME; falls back to .bin only for octet-stream. */ +export function extensionFromMime(mime: string): string { + const m = (mime || '').toLowerCase().trim(); + const ext = MIME_TO_EXT[m]; + if (ext) return ext; + if (m === 'application/octet-stream') return '.bin'; + throw new BadRequestException('unsupported_mime_for_storage'); +} diff --git a/src/storage/storage.constants.ts b/src/storage/storage.constants.ts new file mode 100644 index 0000000..cf931f7 --- /dev/null +++ b/src/storage/storage.constants.ts @@ -0,0 +1,96 @@ +import type { ChatAttachmentFileType } from './storage.types'; + +/** Max sizes (bytes). */ +export const MAX_DICTIONARY_BYTES = 50 * 1024 * 1024; +export const MAX_CHAT_VOICE_BYTES = 25 * 1024 * 1024; +export const MAX_CHAT_IMAGE_BYTES = 15 * 1024 * 1024; +export const MAX_CHAT_DOCUMENT_BYTES = 20 * 1024 * 1024; + +/** Admin dictionary uploads: CSV only (not plain text files). */ +export const DICTIONARY_ALLOWED_MIMES = new Set(['text/csv']); + +/** + * FFmpeg normalizes voice to AAC-LC in MP4 (.m4a / `audio/mp4`) before S3 upload. + */ +export const NORMALIZED_VOICE_MIME = 'audio/mp4'; +export const NORMALIZED_VOICE_EXT = '.m4a'; + +/** MIME → safe extension fallback (avoid trusting original filenames). */ +export const MIME_TO_EXT: Record = { + 'audio/mpeg': '.mp3', + 'audio/mp3': '.mp3', + 'audio/webm': '.webm', + 'audio/wav': '.wav', + 'audio/x-wav': '.wav', + 'audio/wave': '.wav', + 'audio/ogg': '.ogg', + 'audio/mp4': '.m4a', + 'audio/x-m4a': '.m4a', + 'audio/aac': '.aac', + 'audio/flac': '.flac', + 'audio/x-flac': '.flac', + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif', + 'application/pdf': '.pdf', + 'text/csv': '.csv', + 'text/plain': '.txt', + 'application/octet-stream': '.bin', +}; + +/** Broad acceptance for ingest; FFmpeg is the authoritative decoder. */ +export function isVoiceUploadMimeAccepted(mimeRaw: string | undefined): boolean { + const mime = String(mimeRaw ?? '') + .trim() + .toLowerCase(); + if (!mime || mime === 'application/octet-stream') return true; + if (mime.startsWith('audio/')) return true; + if ( + mime === 'video/webm' || + mime === 'video/mp4' || + mime === 'video/quicktime' + ) { + return true; + } + return false; +} + +const IMAGE_MIMES = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', +]); + +const DOCUMENT_MIMES = new Set(['application/pdf']); + +export function allowedMimeForChatFileType( + fileType: ChatAttachmentFileType, + mime: string, +): boolean { + const m = mime.trim().toLowerCase(); + switch (fileType) { + case 'voice': + return isVoiceUploadMimeAccepted(mime); + case 'image': + return IMAGE_MIMES.has(m); + case 'document': + return DOCUMENT_MIMES.has(m); + default: + return false; + } +} + +export function maxBytesForChatFileType(fileType: ChatAttachmentFileType): number { + switch (fileType) { + case 'voice': + return MAX_CHAT_VOICE_BYTES; + case 'image': + return MAX_CHAT_IMAGE_BYTES; + case 'document': + return MAX_CHAT_DOCUMENT_BYTES; + default: + return MAX_CHAT_DOCUMENT_BYTES; + } +} diff --git a/src/storage/storage.errors.ts b/src/storage/storage.errors.ts new file mode 100644 index 0000000..a4a0e46 --- /dev/null +++ b/src/storage/storage.errors.ts @@ -0,0 +1,7 @@ +export class StorageConfigurationError extends Error { + readonly name = 'StorageConfigurationError'; +} + +export class StorageUploadError extends Error { + readonly name = 'StorageUploadError'; +} diff --git a/src/storage/storage.module.ts b/src/storage/storage.module.ts new file mode 100644 index 0000000..9f41131 --- /dev/null +++ b/src/storage/storage.module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { StorageService } from './storage.service'; + +@Global() +@Module({ + imports: [ + HttpModule.register({ + timeout: 300_000, + maxRedirects: 0, + }), + ], + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/src/storage/storage.service.ts b/src/storage/storage.service.ts new file mode 100644 index 0000000..576aa74 --- /dev/null +++ b/src/storage/storage.service.ts @@ -0,0 +1,217 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import FormData = require('form-data'); +import type { Readable } from 'node:stream'; +import type { StorageBucketKind } from './storage.types'; +import { StorageConfigurationError, StorageUploadError } from './storage.errors'; + +/** + * Alarik object storage HTTP API (not raw S3 XML). + * + * Upload: POST /api/v1/objects?bucket=&prefix= + * multipart field `data` (file), headers X-Access-Key, X-Secret-Key + * Download: POST /api/v1/objects/download JSON { bucket, keys: string[] } + */ +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name); + + constructor( + private readonly config: ConfigService, + private readonly http: HttpService, + ) {} + + private get baseUrl(): string { + return this.normalizeBaseUrl(this.config.get('OS_URL')); + } + + private normalizeBaseUrl(raw: string | undefined): string { + const s = raw?.trim(); + if (!s) return ''; + if (s.startsWith('http://') || s.startsWith('https://')) + return s.replace(/\/$/, ''); + return `http://${s}`.replace(/\/$/, ''); + } + + /** Prefer OS_ACCESS_KEY / OS_SECRET_KEY; fall back to OS_USER / OS_PASSWORD. */ + private getAccessSecret(): { accessKey: string; secretKey: string } { + const accessKey = + this.config.get('OS_ACCESS_KEY')?.trim() || + this.config.get('OS_USER')?.trim(); + const secretKey = + this.config.get('OS_SECRET_KEY')?.trim() || + this.config.get('OS_PASSWORD')?.trim(); + return { accessKey: accessKey ?? '', secretKey: secretKey ?? '' }; + } + + private authHeaders(): Record { + const { accessKey, secretKey } = this.getAccessSecret(); + if (!accessKey || !secretKey) { + throw new StorageConfigurationError( + 'Object storage missing OS_ACCESS_KEY/OS_SECRET_KEY (or legacy OS_USER/OS_PASSWORD)', + ); + } + return { + 'X-Access-Key': accessKey, + 'X-Secret-Key': secretKey, + }; + } + + private assertConfigured(): void { + if (!this.baseUrl) { + throw new StorageConfigurationError('Object storage missing OS_URL'); + } + const { accessKey, secretKey } = this.getAccessSecret(); + if (!accessKey || !secretKey) { + throw new StorageConfigurationError( + 'Object storage missing OS_ACCESS_KEY/OS_SECRET_KEY (or legacy OS_USER/OS_PASSWORD)', + ); + } + } + + getBucketName(kind: StorageBucketKind): string { + const key = kind === 'private' ? 'OS_BUCKET_PRIVATE' : 'OS_BUCKET_PUBLIC'; + const name = this.config.get(key)?.trim(); + if (!name) { + throw new StorageConfigurationError( + kind === 'private' + ? 'OS_BUCKET_PRIVATE is not configured' + : 'OS_BUCKET_PUBLIC is not configured', + ); + } + return name; + } + + /** + * `prefix` = directory ending in `/`; last segment of `key` is sent as the uploaded file name. + */ + private splitKeyForUpload(objectKey: string): { prefix: string; filename: string } { + const k = (objectKey || '').replace(/^\//, ''); + const i = k.lastIndexOf('/'); + if (i === -1) { + return { prefix: '', filename: k || 'object' }; + } + return { + prefix: k.slice(0, i + 1), + filename: k.slice(i + 1) || 'object', + }; + } + + private formatAxiosError(e: unknown): string { + if (e && typeof e === 'object' && 'response' in e) { + const r = (e as { response?: { status?: number; data?: unknown } }).response; + const data = r?.data; + if (data != null) { + const body = typeof data === 'string' ? data : JSON.stringify(data); + return `HTTP ${r?.status ?? '?'} ${body.slice(0, 500)}`; + } + } + return e instanceof Error ? e.message : String(e); + } + + /** + * Upload into the private bucket via Alarik multipart `data` field. + */ + async putPrivateObject(params: { + key: string; + body: Buffer | Uint8Array | Readable; + contentType: string; + contentLength?: number; + metadata?: Record; + }): Promise { + this.assertConfigured(); + const bucket = this.getBucketName('private'); + const { prefix, filename } = this.splitKeyForUpload(params.key); + const url = `${this.baseUrl}/api/v1/objects`; + + const form = new FormData(); + const appendOpts: FormData.AppendOptions = { + filename, + contentType: params.contentType, + }; + if (params.contentLength !== undefined) { + appendOpts.knownLength = params.contentLength; + } + const body = params.body; + if (Buffer.isBuffer(body)) { + form.append('data', body, appendOpts); + } else if (body instanceof Uint8Array) { + form.append('data', Buffer.from(body), appendOpts); + } else { + form.append('data', body, appendOpts); + } + + try { + await this.http.axiosRef.post(url, form, { + params: { + bucket, + prefix, + }, + headers: { + ...this.authHeaders(), + ...form.getHeaders(), + }, + maxBodyLength: Infinity, + maxContentLength: Infinity, + timeout: 300_000, + validateStatus: (s) => s >= 200 && s < 300, + }); + void params.metadata; + } catch (e: unknown) { + const msg = this.formatAxiosError(e); + this.logger.error(`putPrivateObject failed key=${params.key}`, e as Error); + throw new StorageUploadError(msg); + } + } + + async getObjectReadable(params: { + kind: StorageBucketKind; + key: string; + }): Promise { + this.assertConfigured(); + const bucket = this.getBucketName(params.kind); + const url = `${this.baseUrl}/api/v1/objects/download`; + + try { + const res = await this.http.axiosRef.post( + url, + { bucket, keys: [params.key] }, + { + headers: { + ...this.authHeaders(), + 'Content-Type': 'application/json', + }, + responseType: 'stream', + timeout: 300_000, + validateStatus: (s) => s >= 200 && s < 300, + }, + ); + const stream = res.data as Readable; + if (!stream || typeof stream.pipe !== 'function') { + throw new StorageUploadError('empty_object_body'); + } + return stream; + } catch (e: unknown) { + this.logger.error(`getObjectReadable failed key=${params.key}`, e as Error); + throw new StorageUploadError(this.formatAxiosError(e)); + } + } + + async deletePrivateObject(key: string): Promise { + this.logger.warn( + `deletePrivateObject not mapped to Alarik HTTP API (key=${key}); no-op`, + ); + } + + async presignDownloadUrl(params: { + kind: StorageBucketKind; + key: string; + expiresInSeconds: number; + }): Promise { + this.logger.warn('presigned download URLs not enabled for Alarik HTTP API', params); + throw new StorageUploadError( + 'presigned_download_urls_not_configured_use_download_endpoint_or_proxy', + ); + } +} diff --git a/src/storage/storage.types.ts b/src/storage/storage.types.ts new file mode 100644 index 0000000..2e74833 --- /dev/null +++ b/src/storage/storage.types.ts @@ -0,0 +1,5 @@ +/** Logical bucket tier (maps to configured bucket names). */ +export type StorageBucketKind = 'private' | 'public'; + +/** Chat attachment subdirectory under `chats/...`. */ +export type ChatAttachmentFileType = 'voice' | 'image' | 'document'; diff --git a/src/upload/upload.controller.ts b/src/upload/upload.controller.ts new file mode 100644 index 0000000..549fb22 --- /dev/null +++ b/src/upload/upload.controller.ts @@ -0,0 +1,100 @@ +import { createReadStream } from 'node:fs'; +import { + Body, + Controller, + Get, + NotFoundException, + Param, + Post, + Req, + Res, + StreamableFile, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Response } from 'express'; +import { UploadFileDto } from './uploadFile.dto'; +import { UploadService } from './upload.service'; + +@ApiTags('Upload & Download Center') +@Controller('upload') +@ApiBearerAuth() +export class UploadController { + constructor(private readonly uploadService: UploadService) {} + @Post() + @ApiOperation({ + summary: + 'Upload files all over the app and use the _id where you need to submit the file with the form', + }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file'], + properties: { + file: { + type: 'string', + format: 'binary', + }, + type: { type: 'string', example: 'csv' }, + category: { type: 'string', example: 'dictionary' }, + }, + }, + }) + @UseInterceptors(FileInterceptor('file')) + uploadFile( + @UploadedFile() file: Express.Multer.File, + @Body() uploadFileDto: UploadFileDto, + @Req() req: Request, + ) { + + return this.uploadService.handleFileUpload(file, uploadFileDto, req); + } + + @Get('stream/:fileId') + @ApiParam({ name: 'fileId', description: 'The ID of the file to download' }) + @ApiResponse({ status: 200, description: 'File downloaded successfully' }) + @ApiResponse({ status: 400, description: 'Bad Request' }) + async stream(@Param('fileId') fileId: string, @Res() res: Response) { + const file = await this.uploadService.getFileMetadata(fileId); + const fileStream = await this.uploadService.stream(fileId); + + res.setHeader('Content-Type', file.mimetype); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${file.originalName}"`, + ); + + fileStream.pipe(res); + } + + @Get('download/:fileId') + @ApiOperation({ summary: 'Download a file by fileId' }) + @ApiParam({ name: 'fileId', description: 'The ID of the file to download' }) + @ApiResponse({ status: 200, description: 'File downloaded successfully' }) + @ApiResponse({ status: 404, description: 'File not found' }) + async downloadFile(@Param('fileId') fileId: string) { + // return this.uploadService.downloadFiles(fileId); + const fileData = await this.uploadService.downloadFiles(fileId); + + if (!fileData) { + throw new NotFoundException('File not found'); + } + + const fileStream = createReadStream(fileData.filePath); + + return new StreamableFile(fileStream, { + disposition: `attachment; filename="${fileData.originalName}"`, + }); + } +} diff --git a/src/upload/upload.module.ts b/src/upload/upload.module.ts new file mode 100644 index 0000000..53fcffb --- /dev/null +++ b/src/upload/upload.module.ts @@ -0,0 +1,40 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { MulterModule } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import { DatabaseModule } from 'src/database/database.module'; +import { UploadController } from './upload.controller'; +import { UploadService } from './upload.service'; + +@Module({ + imports: [ + MulterModule.register({ + storage: diskStorage({ + destination: './uploads', + filename: (req, file, cb) => { + const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const filename = `${uniqueSuffix}-${file.originalname}`; + cb(null, filename); + }, + }), + fileFilter: (req, file, cb) => { + const allowedMimeTypes = [ + 'image/jpeg', + 'image/png', + 'application/pdf', + 'text/csv', + 'text/plain' + ]; + if (allowedMimeTypes.includes(file.mimetype)) { + cb(null, true); // Accept the file + } else { + cb(null, false); // Reject the file + } + }, + }), + forwardRef(() => DatabaseModule), + ], + controllers: [UploadController], + providers: [UploadService], + exports: [UploadService], // Export for reuse in other modules +}) +export class UploadModule {} diff --git a/src/upload/upload.service.ts b/src/upload/upload.service.ts new file mode 100644 index 0000000..805aaa4 --- /dev/null +++ b/src/upload/upload.service.ts @@ -0,0 +1,134 @@ +import { createReadStream } from 'node:fs'; +import { unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; +import { AdminModel } from 'src/database/model/admin.model'; +import { FileUploadModel } from 'src/database/model/fileUpload.model'; + +@Injectable() +export class UploadService { + constructor( + @InjectModel(FileUploadModel.name) + private readonly fileModel: Model, + @InjectModel(AdminModel.name) + private readonly adminModel: Model, + ) {} + async handleFileUpload(file: Express.Multer.File, metadata: any, req) { + try { + // Validate file type + const allowedMimeTypes = [ + 'image/jpeg', + 'image/png', + 'application/pdf', + 'text/csv', + 'text/plain', + ]; + if (!file || !allowedMimeTypes.includes(file.mimetype)) { + throw new HttpException('invalid_file_type', HttpStatus.BAD_REQUEST); + } + + // Validate file size (e.g., max 5MB) + const maxSize = 5 * 1024 * 1024; + if (file.size > maxSize) { + throw new HttpException('file_large', HttpStatus.BAD_REQUEST); + } + + // File is valid, return file path and metadata + const filePath = join('./uploads', file.filename); + + const fileData = await this.fileModel.create({ + originalName: file.originalname, + filename: file.filename, + filePath: filePath, + mimetype: file.mimetype, + size: file.size, + type: metadata.type, + category: metadata.category, + }); + + if (metadata.category === 'avatar' && req.user) { + const userId = req.user._id; + + const user = await this.adminModel.findById(userId); + + if (user && user.avatar) { + await this.deleteFile(user.avatar.toString()); + } + + await this.adminModel.findByIdAndUpdate(userId, { + avatar: fileData._id, + }); + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + _id: fileData._id, + name: fileData.filename, + }); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, err.response, null); + } + } + + async stream(fileId: string) { + try { + const file = await this.fileModel.findOne({ _id: fileId }); + if (!file) { + throw new BaseResponseDTO(HttpStatus.NOT_FOUND, 'File not found', null); + } + return createReadStream(join(process.cwd(), `${file.filePath}`)); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, err.response, null); + } + } + + async getFileMetadata(fileId: string) { + try { + const file = await this.fileModel.findOne({ _id: fileId }); + if (!file) { + throw new BaseResponseDTO(HttpStatus.NOT_FOUND, 'File not found', null); + } + return file; // Return the file metadata + } catch (err) { + console.log(err); + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, err.response, null); + } + } + + async downloadFiles(fileId: string) { + try { + const file = await this.fileModel.findOne({ _id: fileId }); + + if (!file) + throw new HttpException('fild_not_found', HttpStatus.NOT_FOUND); + return { + filePath: `http://${process.env.BASEURL}:${process.env.PORT}/${file.filePath}`, + originalName: file.originalName, + }; + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async deleteFile(fileId: string): Promise { + try { + const file = await this.fileModel.findById(fileId); + + if (file) { + await unlink(file.filePath); + + await this.fileModel.findByIdAndDelete(fileId); + } + } catch (error) { + console.error( + `Failed to delete old avatar file (ID: ${fileId}):`, + error.message, + ); + } + } +} diff --git a/src/upload/uploadFile.dto.ts b/src/upload/uploadFile.dto.ts new file mode 100644 index 0000000..d7ef462 --- /dev/null +++ b/src/upload/uploadFile.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class UploadFileDto { + @IsNotEmpty() + @IsString() + type: string; // e.g., 'image', 'document' + + @IsNotEmpty() + @IsString() + category: string; // e.g., 'profile', 'invoice' +} diff --git a/src/user-management/dto/create-user-management.dto.ts b/src/user-management/dto/create-user-management.dto.ts new file mode 100644 index 0000000..8f588c0 --- /dev/null +++ b/src/user-management/dto/create-user-management.dto.ts @@ -0,0 +1 @@ +export class CreateUserManagementDto {} diff --git a/src/user-management/dto/find-experts-filter.dto.ts b/src/user-management/dto/find-experts-filter.dto.ts new file mode 100644 index 0000000..d6f8cce --- /dev/null +++ b/src/user-management/dto/find-experts-filter.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; + +export enum ActivityType { + START_ACTIVITY = 'startActivity', + LAST_ACTIVITY = 'lastActivity', +} + +export class FindExpertsFilterDto { + @ApiProperty({ + description: 'Filter by average rating (1-5)', + required: false, + example: '4', + }) + @IsOptional() + @IsString() + rate?: string; + + @ApiProperty({ + description: + 'Filter by session date range in Persian format (YYYY/MM/DD-YYYY/MM/DD)', + required: false, + example: '1403/07/01-1403/11/07', + }) + @IsOptional() + @IsString() + date?: string; + + @ApiProperty({ + description: 'Filter by activity type', + required: false, + enum: ActivityType, + example: ActivityType.START_ACTIVITY, + }) + @IsOptional() + @IsEnum(ActivityType) + activityType?: ActivityType; + + @ApiProperty({ + description: 'Search by expert mobile or other fields', + required: false, + example: '09999985840', + }) + @IsOptional() + @IsString() + search?: string; +} diff --git a/src/user-management/dto/update-user-management.dto.ts b/src/user-management/dto/update-user-management.dto.ts new file mode 100644 index 0000000..b401ca5 --- /dev/null +++ b/src/user-management/dto/update-user-management.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateUserManagementDto } from './create-user-management.dto'; + +export class UpdateUserManagementDto extends PartialType( + CreateUserManagementDto, +) {} diff --git a/src/user-management/user-management.controller.ts b/src/user-management/user-management.controller.ts new file mode 100644 index 0000000..129a205 --- /dev/null +++ b/src/user-management/user-management.controller.ts @@ -0,0 +1,250 @@ +import { Controller, Get, Param, UseGuards, Query, Res } from '@nestjs/common'; +import { Response } from 'express'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { AdminGuard } from 'src/auth/guards/admin.guard'; +import { AdminIdentity } from 'src/common/decorators/Identity.decorator'; +import { Permissions } from 'src/common/decorators/permission.decorator'; +import { Permission } from 'src/common/types/permissions.catalog'; +import { AdminModel } from 'src/database/model/admin.model'; +import { + FindExpertsFilterDto, + ActivityType, +} from './dto/find-experts-filter.dto'; +import { UserManagementService } from './user-management.service'; + +@ApiBearerAuth() +@UseGuards(AdminGuard) +@ApiTags('user-management-module') +@Controller('user-management') +export class UserManagementController { + constructor(private readonly userManagementService: UserManagementService) {} + + @Permissions(Permission.UsersList) + @ApiOperation({ + summary: + 'Get all the users which is connected to current expert before or get all the conversations for a expert if using expert filter', + }) + @ApiQuery({ + name: 'user', + required: false, + type: String, + description: 'Users mobile filter', + example: '09226187419', + }) + @ApiQuery({ + name: 'expert', + required: false, + type: String, + description: 'Expert username filter for admin', + example: 'expert@chatbot.com', + }) + @ApiQuery({ + name: 'page', + required: false, + type: Number, + description: 'Page number (starts from 1)', + example: 1, + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Number of items per page', + example: 50, + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: 'Filter by date range in Persian format (YYYY/MM/DD-YYYY/MM/DD). Filters users whose firstQuestionDate or lastQuestionDate falls within the range.', + example: '1403/07/01-1403/11/07', + }) + @Get('list') + findAllAnsweredUsers( + @AdminIdentity() adminIdentity: AdminModel, + @Query('user') user?: string, + @Query('expert') expert?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('date') date?: string, + ) { + const pageNum = page ? parseInt(page, 10) : 1; + const limitNum = limit ? parseInt(limit, 10) : 50; + + return this.userManagementService.findAllAnsweredUsers(adminIdentity, { + user, + expert, + page: pageNum, + limit: limitNum, + date, + }); + } + + @Permissions(Permission.UsersExport) + @ApiOperation({ + summary: 'Export users list to Excel', + description: 'Exports all users matching the filters to an Excel file. Includes the same data as the list endpoint but without pagination.', + }) + @ApiQuery({ + name: 'user', + required: false, + type: String, + description: 'Users mobile filter', + example: '09226187419', + }) + @ApiQuery({ + name: 'expert', + required: false, + type: String, + description: 'Expert username filter for admin', + example: 'expert@chatbot.com', + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: 'Filter by date range in Persian format (YYYY/MM/DD-YYYY/MM/DD). Filters users whose firstQuestionDate or lastQuestionDate falls within the range.', + example: '1403/07/01-1403/11/07', + }) + @Get('list/export-excel') + async exportUsersToExcel( + @AdminIdentity() adminIdentity: AdminModel, + @Query('user') user?: string, + @Query('expert') expert?: string, + @Query('date') date?: string, + @Res() res?: Response, + ) { + const buffer = await this.userManagementService.exportUsersToExcel(adminIdentity, { + user, + expert, + date, + }); + + const filename = `users-list-${new Date().getTime()}.xlsx`; + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); + } + + @Permissions(Permission.UsersList) + @ApiOperation({ + summary: 'Get the list of the sessions of one user', + }) + @ApiParam({ name: 'userId' }) + @ApiQuery({ + name: 'mobile', + required: false, + type: String, + description: 'user mobile', + example: '09226187419', + }) + @Get('list/:userId') + findUserSessions( + @Param('userId') userId: string, + @AdminIdentity() adminIdentity: AdminModel, + @Query('mobile') mobile?: string, + ) { + return this.userManagementService.findUserSessions( + userId, + adminIdentity, + mobile, + ); + } + + @Permissions(Permission.ExpertsList) + @ApiOperation({ + summary: 'Get all experts with optional filtering', + description: + 'Returns a list of all experts with their session statistics. Can be filtered by rate, date range, and activity type.', + }) + @ApiQuery({ + name: 'rate', + required: false, + type: Number, + description: + 'Filter by average rating (1-5). Returns experts with average rating equal to the specified value (floored).', + example: 4, + }) + @ApiQuery({ + name: 'date', + required: false, + type: String, + description: + 'Filter by session date range in Persian format (YYYY/MM/DD-YYYY/MM/DD)', + example: '1403/07/01-1403/11/07', + }) + @ApiQuery({ + name: 'activityType', + required: false, + enum: ['startActivity', 'lastActivity'], + description: + 'Filter by activity type. startActivity: sessions started in date range, lastActivity: sessions ended in date range', + example: 'startActivity', + }) + @ApiQuery({ + name: 'search', + required: false, + type: String, + description: 'search by the expert mobile or others fields', + example: '09999985840', + }) + @Get('/experts/list') + async findAllExperts( + @AdminIdentity() adminIdentity: AdminModel, + @Query('rate') rate?: string, + @Query('date') date?: string, + @Query('activityType') activityType?: ActivityType, + @Query('search') search?: string, + ) { + // Convert empty string to undefined + const effectiveRate = rate?.trim() ? rate : undefined; + const effectiveDate = date?.trim() ? date : undefined; + const effectiveSearch = search?.trim() ? search : undefined; + const effectiveActivityType = activityType?.trim() + ? activityType + : undefined; + + const filter: FindExpertsFilterDto = { + rate: effectiveRate, + date: effectiveDate, + search: effectiveSearch, + activityType: effectiveActivityType, + }; + + return this.userManagementService.findExpertsByFilter( + adminIdentity, + filter, + ); + } + + @Permissions(Permission.ExpertsReport) + @ApiOperation({ + summary: 'Admin gets an expert functionality report', + }) + @ApiParam({ name: 'expertId' }) + @Get('/experts/:expertId/report') + expertReports( + @Param('expertId') expertId: string, + @AdminIdentity() adminIdentity: AdminModel, + ) { + return this.userManagementService.expertReports(expertId); + } + + @Permissions(Permission.ConversationsOwn) + @ApiOperation({ + summary: 'Expert can get the history of all his chats', + }) + @Get('/experts/sessions') + expertAllSessions(@AdminIdentity() adminIdentity: AdminModel) { + return this.userManagementService.expertAllSessions(adminIdentity); + } +} diff --git a/src/user-management/user-management.module.ts b/src/user-management/user-management.module.ts new file mode 100644 index 0000000..dc3c17d --- /dev/null +++ b/src/user-management/user-management.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from 'src/database/database.module'; +import { UserManagementController } from './user-management.controller'; +import { UserManagementService } from './user-management.service'; + +@Module({ + imports: [DatabaseModule], + controllers: [UserManagementController], + providers: [UserManagementService], + exports: [UserManagementService], +}) +export class UserManagementModule {} diff --git a/src/user-management/user-management.service.ts b/src/user-management/user-management.service.ts new file mode 100644 index 0000000..661f730 --- /dev/null +++ b/src/user-management/user-management.service.ts @@ -0,0 +1,1063 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { BaseResponseDTO, PageOptionsDto, PageMetaDto } from 'src/common/dto/base-response.dto'; +import { TimeHelper } from 'src/common/tools/time-helper'; +import { AdminModel } from 'src/database/model/admin.model'; +import { SessionModel } from 'src/database/model/sessions.model'; +import { UserModel } from 'src/database/model/user.model'; +import { ReassignedLogsModel } from 'src/database/model/reassigned-logs.model'; +import { ActivityType } from './dto/find-experts-filter.dto'; +import { Role } from 'src/common/types/role.type'; +import { RedisService } from 'src/common/helpers/redis.service'; +import * as ExcelJS from 'exceljs'; + +@Injectable() +export class UserManagementService { + constructor( + @InjectModel(UserModel.name) private readonly user: Model, + @InjectModel(AdminModel.name) private readonly admin: Model, + @InjectModel(SessionModel.name) + private readonly session: Model, + @InjectModel(ReassignedLogsModel.name) + private readonly reassignedLogs: Model, + private readonly redisService: RedisService, + ) {} + + async findAllAnsweredUsers(adminIdentity, filter) { + try { + // Build session query + const sessionQuery: any = { + // connectedToExpert: true, + // onlineChatClosed: true, + }; + + if (adminIdentity.userData.role === Role.Expert) { + sessionQuery.expert = adminIdentity.userData.username; + } else if (filter.expert) { + sessionQuery.expert = filter.expert; + } + + // Fetch sessions with only required fields, lean for performance + const sessions = await this.session + .find(sessionQuery) + .select('userId messages expertRate') + .lean() + .exec(); + + if (!sessions.length) { + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', []); + } + + // Group sessions by userId for faster lookup + const sessionsByUser = new Map(); + const userIdSet = new Set(); + + for (const session of sessions) { + const userId = session.userId.toString(); + userIdSet.add(userId); + + if (!sessionsByUser.has(userId)) { + sessionsByUser.set(userId, []); + } + sessionsByUser.get(userId)!.push(session); + } + + // Build user query + const userQuery: any = { _id: { $in: Array.from(userIdSet) } }; + if (filter.user) { + userQuery.mobile = filter.user; + } + + // Fetch users with only required fields, lean for performance + const users = await this.user + .find(userQuery) + .select('_id name family mobile') + .lean() + .exec(); + + // Process users - no async needed, all data is in memory + const userStats = users.map((user) => { + const userId = user._id.toString(); + const userSessions = sessionsByUser.get(userId) || []; + + // Count total sessions (not messages) + let totalAsked = userSessions.length; + + // Single pass through all messages to find first/last question dates + let firstQuestionDate: [string, string] | null = null; + let lastQuestionDate: [string, string] | null = null; + let firstQuestionTime: number | null = null; + let lastQuestionTime: number | null = null; + let rateSum = 0; + let rateCount = 0; + + for (const session of userSessions) { + const messages = session.messages || []; + + for (const msg of messages) { + if (msg.sender === 'User') { + + // Track first and last question dates using createdISO for accurate comparison + if (msg.createdISO) { + const msgTime = new Date(msg.createdISO).getTime(); + + if (!firstQuestionTime || msgTime < firstQuestionTime) { + firstQuestionTime = msgTime; + firstQuestionDate = msg.createdAt || null; + } + + if (!lastQuestionTime || msgTime > lastQuestionTime) { + lastQuestionTime = msgTime; + lastQuestionDate = msg.createdAt || null; + } + } + } + } + + // Accumulate rates + if (session.expertRate !== undefined && session.expertRate !== null) { + rateSum += session.expertRate; + rateCount++; + } + } + + const averageRate = rateCount > 0 ? Math.round(rateSum / rateCount) : 0; + + return { + _id: user._id, + name: user.name, + family: user.family, + mobile: user.mobile, + firstQuestionDate, + lastQuestionDate, + firstQuestionTime, // Store timestamp for date range filtering + lastQuestionTime, // Store timestamp for date range filtering + totalAsked, + averageRate, + }; + }); + + // Apply date range filter if provided + let filteredUserStats = userStats; + if (filter.date) { + const dateParts = filter.date.split('-'); + + if (dateParts.length < 2 || !dateParts[0] || !dateParts[1]) { + throw new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + 'Invalid date range format. Expected "YYYY/MM/DD-YYYY/MM/DD".', + null, + ); + } + + const [startDateStr, endDateStr] = dateParts.map(d => d.trim()); + const startISO = TimeHelper.jalaliToISO(startDateStr); + const endISO = TimeHelper.jalaliToISO(endDateStr); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + const startTime = startDateObj.getTime(); + const endTime = endDateObj.getTime(); + + // Filter users where either firstQuestionTime or lastQuestionTime falls within the range + // Use the stored timestamps (from createdISO) for accurate comparison + filteredUserStats = userStats.filter((user) => { + const firstTime = user.firstQuestionTime; + const lastTime = user.lastQuestionTime; + + // Include user if either first or last question timestamp is within range + const firstInRange = firstTime !== null && firstTime >= startTime && firstTime <= endTime; + const lastInRange = lastTime !== null && lastTime >= startTime && lastTime <= endTime; + + return firstInRange || lastInRange; + }); + } + + // Apply pagination + const page = filter.page || 1; + const limit = filter.limit || 50; + const skip = (page - 1) * limit; + const totalCount = filteredUserStats.length; + + // Sort by lastQuestionDate (newest first), then by firstQuestionDate + filteredUserStats.sort((a, b) => { + // Helper function to safely get date string from date array + const getDateString = (dateArray: [string, string] | null): string | null => { + if (!dateArray || !Array.isArray(dateArray) || dateArray.length < 2) { + return null; + } + return dateArray[1] || null; + }; + + // If both have lastQuestionDate with valid date strings, compare them + const dateA = getDateString(a.lastQuestionDate); + const dateB = getDateString(b.lastQuestionDate); + + if (dateA && dateB) { + // Compare dates in reverse order (newest first) + return dateB.localeCompare(dateA); + } + + // If only one has lastQuestionDate, prioritize it + if (dateA && !dateB) return -1; + if (!dateA && dateB) return 1; + + // If neither has lastQuestionDate, compare by firstQuestionDate + const firstDateA = getDateString(a.firstQuestionDate); + const firstDateB = getDateString(b.firstQuestionDate); + + if (firstDateA && firstDateB) { + return firstDateB.localeCompare(firstDateA); + } + + // If only one has firstQuestionDate, prioritize it + if (firstDateA && !firstDateB) return -1; + if (!firstDateA && firstDateB) return 1; + + return 0; + }); + + // Apply pagination to sorted results + const paginatedUserStats = filteredUserStats.slice(skip, skip + limit); + + // Remove the timestamp fields from the response (they were only for filtering) + const cleanedResults = paginatedUserStats.map(({ firstQuestionTime, lastQuestionTime, ...rest }) => rest); + + // Create pagination metadata + const pageOptions = Object.assign(new PageOptionsDto(), { + page: page, + take: limit, + }); + const meta = new PageMetaDto({ pageOptionsDto: pageOptions, itemCount: totalCount }); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', cleanedResults, meta); + } catch (err) { + console.error('Error in findAllAnsweredUsers:', err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async exportUsersToExcel(adminIdentity, filter): Promise { + try { + // Reuse the same logic as findAllAnsweredUsers but get all users (no pagination) + // Build session query + const sessionQuery: any = {}; + + if (adminIdentity.userData.role === Role.Expert) { + sessionQuery.expert = adminIdentity.userData.username; + } else if (filter.expert) { + sessionQuery.expert = filter.expert; + } + + // Fetch sessions with only required fields, lean for performance + const sessions = await this.session + .find(sessionQuery) + .select('userId messages expertRate') + .lean() + .exec(); + + if (!sessions.length) { + // Return empty Excel file + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Chatbot V2 API'; + workbook.created = new Date(); + const sheet = workbook.addWorksheet('Users List'); + sheet.getCell('A1').value = 'No users found'; + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + } + + // Group sessions by userId for faster lookup + const sessionsByUser = new Map(); + const userIdSet = new Set(); + + for (const session of sessions) { + const userId = session.userId.toString(); + userIdSet.add(userId); + + if (!sessionsByUser.has(userId)) { + sessionsByUser.set(userId, []); + } + sessionsByUser.get(userId)!.push(session); + } + + // Build user query + const userQuery: any = { _id: { $in: Array.from(userIdSet) } }; + if (filter.user) { + userQuery.mobile = filter.user; + } + + // Fetch users with only required fields, lean for performance + const users = await this.user + .find(userQuery) + .select('_id name family mobile') + .lean() + .exec(); + + // Process users - same logic as findAllAnsweredUsers + const userStats = users.map((user) => { + const userId = user._id.toString(); + const userSessions = sessionsByUser.get(userId) || []; + + let totalAsked = userSessions.length; + + let firstQuestionDate: [string, string] | null = null; + let lastQuestionDate: [string, string] | null = null; + let firstQuestionTime: number | null = null; + let lastQuestionTime: number | null = null; + let rateSum = 0; + let rateCount = 0; + + for (const session of userSessions) { + const messages = session.messages || []; + + for (const msg of messages) { + if (msg.sender === 'User') { + if (msg.createdISO) { + const msgTime = new Date(msg.createdISO).getTime(); + + if (!firstQuestionTime || msgTime < firstQuestionTime) { + firstQuestionTime = msgTime; + firstQuestionDate = msg.createdAt || null; + } + + if (!lastQuestionTime || msgTime > lastQuestionTime) { + lastQuestionTime = msgTime; + lastQuestionDate = msg.createdAt || null; + } + } + } + } + + if (session.expertRate !== undefined && session.expertRate !== null) { + rateSum += session.expertRate; + rateCount++; + } + } + + const averageRate = rateCount > 0 ? Math.round(rateSum / rateCount) : 0; + + return { + _id: user._id, + name: user.name, + family: user.family, + mobile: user.mobile, + firstQuestionDate, + lastQuestionDate, + firstQuestionTime, + lastQuestionTime, + totalAsked, + averageRate, + }; + }); + + // Apply date range filter if provided + let filteredUserStats = userStats; + if (filter.date) { + const dateParts = filter.date.split('-'); + + if (dateParts.length < 2 || !dateParts[0] || !dateParts[1]) { + throw new BaseResponseDTO( + HttpStatus.BAD_REQUEST, + 'Invalid date range format. Expected "YYYY/MM/DD-YYYY/MM/DD".', + null, + ); + } + + const [startDateStr, endDateStr] = dateParts.map(d => d.trim()); + const startISO = TimeHelper.jalaliToISO(startDateStr); + const endISO = TimeHelper.jalaliToISO(endDateStr); + const startDateObj = new Date(startISO); + const endDateObj = new Date(endISO); + startDateObj.setHours(0, 0, 0, 0); + endDateObj.setHours(23, 59, 59, 999); + + const startTime = startDateObj.getTime(); + const endTime = endDateObj.getTime(); + + filteredUserStats = userStats.filter((user) => { + const firstTime = user.firstQuestionTime; + const lastTime = user.lastQuestionTime; + + const firstInRange = firstTime !== null && firstTime >= startTime && firstTime <= endTime; + const lastInRange = lastTime !== null && lastTime >= startTime && lastTime <= endTime; + + return firstInRange || lastInRange; + }); + } + + // Sort by lastQuestionDate (newest first), then by firstQuestionDate + filteredUserStats.sort((a, b) => { + const getDateString = (dateArray: [string, string] | null): string | null => { + if (!dateArray || !Array.isArray(dateArray) || dateArray.length < 2) { + return null; + } + return dateArray[1] || null; + }; + + const dateA = getDateString(a.lastQuestionDate); + const dateB = getDateString(b.lastQuestionDate); + + if (dateA && dateB) { + return dateB.localeCompare(dateA); + } + + if (dateA && !dateB) return -1; + if (!dateA && dateB) return 1; + + const firstDateA = getDateString(a.firstQuestionDate); + const firstDateB = getDateString(b.firstQuestionDate); + + if (firstDateA && firstDateB) { + return firstDateB.localeCompare(firstDateA); + } + + if (firstDateA && !firstDateB) return -1; + if (!firstDateA && firstDateB) return 1; + + return 0; + }); + + // Create Excel workbook + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Chatbot V2 API'; + workbook.created = new Date(); + + const sheet = workbook.addWorksheet('Users List'); + + // Add title + sheet.mergeCells('A1:F1'); + sheet.getCell('A1').value = 'Users List'; + sheet.getCell('A1').font = { size: 16, bold: true }; + sheet.getCell('A1').alignment = { horizontal: 'center', vertical: 'middle' }; + sheet.getRow(1).height = 30; + + // Add filter info if provided + let currentRow = 3; + if (filter.date) { + sheet.mergeCells(`A${currentRow}:F${currentRow}`); + sheet.getCell(`A${currentRow}`).value = `Date Range: ${filter.date}`; + sheet.getCell(`A${currentRow}`).font = { size: 12, bold: true }; + sheet.getCell(`A${currentRow}`).alignment = { horizontal: 'center' }; + currentRow++; + } + if (filter.expert) { + sheet.mergeCells(`A${currentRow}:F${currentRow}`); + sheet.getCell(`A${currentRow}`).value = `Expert: ${filter.expert}`; + sheet.getCell(`A${currentRow}`).font = { size: 11, italic: true }; + sheet.getCell(`A${currentRow}`).alignment = { horizontal: 'center' }; + currentRow++; + } + if (filter.user) { + sheet.mergeCells(`A${currentRow}:F${currentRow}`); + sheet.getCell(`A${currentRow}`).value = `User Mobile: ${filter.user}`; + sheet.getCell(`A${currentRow}`).font = { size: 11, italic: true }; + sheet.getCell(`A${currentRow}`).alignment = { horizontal: 'center' }; + currentRow++; + } + + currentRow += 1; + + // Add headers + const headerRow = currentRow; + const headers = ['Mobile', 'Name', 'Family', 'First Question Date', 'Last Question Date', 'Total Asked', 'Average Rate']; + const headerRowObj = sheet.getRow(headerRow); + headerRowObj.values = headers; + headerRowObj.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + headerRowObj.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF4472C4' }, + }; + headerRowObj.alignment = { horizontal: 'center', vertical: 'middle' }; + headerRowObj.height = 25; + + // Helper function to format date array + const formatDate = (dateArray: [string, string] | null): string => { + if (!dateArray || !Array.isArray(dateArray) || dateArray.length < 2) { + return 'N/A'; + } + // Return the date part (second element) and time part (first element) + return `${dateArray[0]} - ${dateArray[1]}`; + }; + + // Add data rows + filteredUserStats.forEach((user, index) => { + const row = sheet.getRow(headerRow + 1 + index); + row.values = [ + user.mobile, + user.name || 'N/A', + user.family || 'N/A', + formatDate(user.firstQuestionDate), + formatDate(user.lastQuestionDate), + user.totalAsked, + user.averageRate, + ]; + row.alignment = { horizontal: 'center', vertical: 'middle' }; + }); + + // Auto-fit columns + sheet.columns.forEach((column) => { + column.width = 20; + }); + + // Generate buffer + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + } catch (err) { + console.error('Error in exportUsersToExcel:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Failed to generate Excel file', + null, + ); + } + } + + async findUsersByExpert(expertUsername, userFilter) { + const matchStage: any = { + chat: { + $elemMatch: { + expert: expertUsername, + }, + }, + }; + if (userFilter) { + matchStage.mobile = userFilter; + } + + return await this.user + .aggregate([ + { + $match: matchStage, + }, + { + $project: { + userId: '$_id', // Rename _id to userId + mobile: 1, + chatLength: { $size: '$chat' }, + createdAt: 1, + updatedAt: 1, + rate: { + $avg: { + $map: { + input: { + $filter: { + input: '$chat', + as: 'chatItem', + cond: { + $and: [ + { $eq: ['$$chatItem.connectedToExpert', true] }, + { $ne: ['$$chatItem.expertRate', null] }, + ], + }, + }, + }, + as: 'filteredChat', + in: '$$filteredChat.expertRate', + }, + }, + }, + }, + }, + ]) + .exec(); + } + + formatUsersWithPersianDates(users) { + return users.map((user) => { + const { createdAt, updatedAt, _id, ...rest } = user; + return { + ...rest, // Include all other fields except createdAt and updatedAt + first: TimeHelper.unix2PersianTimeAndDate( + new Date(createdAt).getTime() / 1000, + ), + last: TimeHelper.unix2PersianTimeAndDate( + new Date(updatedAt).getTime() / 1000, + ), + }; + }); + } + + async findUserSessions(userId: string, adminIdentity, mobile: string) { + try { + const user = await this.user.findOne({ _id: userId }); + if (!user) + throw new HttpException('user_not_found', HttpStatus.NOT_FOUND); + + const filteredChats = user.chat.filter((chat) => { + return ( + chat.connectedToExpert === true && + chat.expert === adminIdentity.userData.username + ); + }); + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', filteredChats); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async expertReports(expertId) { + try { + const admin = await this.admin.findOne( + { _id: new Types.ObjectId(expertId), isActive: true }, + { password: 0, resetToken: 0 }, + ); + + if (!admin) { + throw new HttpException('not_found', HttpStatus.NOT_FOUND); + } + + // Use expert's email to find sessions (expert field stores email) + const expertEmail = admin.email; + + // Calculate total answered: sessions where expert actually connected (connectedToExpert === true) + // and the expert field matches this expert's email + const answeredSessions = await this.session.find({ + expert: expertEmail, + connectedToExpert: true, + onlineStartDate: { $exists: true }, + }); + + // Calculate total answered (sessions where expert connected) + const totalAnswered = answeredSessions.length; + + // Calculate total not answered: count from reassigned_logs where this expert missed the chat + // (reassigned from them, meaning they were online but didn't answer) + const totalNotAnswered = await this.reassignedLogs.countDocuments({ + from: expertEmail, + }); + + // Calculate total sessions (answered + not answered) + const totalSessions = totalAnswered + totalNotAnswered; + + // Calculate average waiting time for expert first response (only from answered sessions) + let totalWaitingTime = 0; + let sessionsWithExpertResponse = 0; + + for (const session of answeredSessions) { + if (session.onlineStartDate && session.messages.length > 0) { + const firstExpertMessage = session.messages.find( + (message) => message.sender === 'Expert', + ); + + if (firstExpertMessage && firstExpertMessage.createdISO) { + const onlineStartDate = new Date(session.onlineStartDate); + const expertResponseTime = new Date(firstExpertMessage.createdISO); + + const waitingTime = expertResponseTime.getTime() - onlineStartDate.getTime(); + if (waitingTime >= 0) { + totalWaitingTime += waitingTime; + sessionsWithExpertResponse++; + } + } + } + } + + const waitingAvgTime = sessionsWithExpertResponse > 0 + ? Math.round(totalWaitingTime / sessionsWithExpertResponse / 1000) + : 0; + + // For activity time, first filter for answered sessions that have valid date ranges. + const sessionsForActivity = answeredSessions.filter( + (session) => session.onlineStartDate && session.onlineEndDate, + ); + + let activityTime = 0; + if (sessionsForActivity.length > 0) { + const sortedSessions = sessionsForActivity.sort( + (a, b) => a.onlineStartDate.getTime() - b.onlineStartDate.getTime(), + ); + + const mergedIntervals = [ + { + start: sortedSessions[0].onlineStartDate, + end: sortedSessions[0].onlineEndDate, + }, + ]; + + for (let i = 1; i < sortedSessions.length; i++) { + const currentSession = sortedSessions[i]; + const lastMerged = mergedIntervals[mergedIntervals.length - 1]; + + if (currentSession.onlineStartDate <= lastMerged.end) { + if (currentSession.onlineEndDate > lastMerged.end) { + lastMerged.end = currentSession.onlineEndDate; + } + } else { + mergedIntervals.push({ + start: currentSession.onlineStartDate, + end: currentSession.onlineEndDate, + }); + } + } + + const totalMilliseconds = mergedIntervals.reduce((total, interval) => { + const duration = interval.end.getTime() - interval.start.getTime(); + return total + (duration > 0 ? duration : 0); + }, 0); + + activityTime = Math.round(totalMilliseconds / 1000); + } + + // Rate calculation - use expertRate from answered sessions + const validRates = answeredSessions + .map((session) => session.expertRate) + .filter((rate) => rate !== null && rate !== undefined); + + // Calculate average rate (rounded to 2 decimal places - same as findExpertsByFilter) + const avgRate = validRates.length > 0 + ? Math.round((validRates.reduce((sum, rate) => sum + rate, 0) / validRates.length) * 100) / 100 + : 0; + + // Calculate non-rated sessions count + const nonRatedCount = totalAnswered - validRates.length; + + // Calculate rate details distribution + const rateDetails = { + 1: validRates.filter((rate) => rate === 1).length, + 2: validRates.filter((rate) => rate === 2).length, + 3: validRates.filter((rate) => rate === 3).length, + 4: validRates.filter((rate) => rate === 4).length, + 5: validRates.filter((rate) => rate === 5).length, + nonRated: nonRatedCount, + avg: avgRate, + }; + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { + _id: admin._id, + name: admin.name, + family: admin.family, + mobile: admin.mobile, + email: admin.email, + username: admin.username, + role: admin.role, + isActive: admin.isActive, + reports: { + activityTime, + totalSessions, + totalAnswered, + totalNotAnswered, + totalRate: avgRate, + rateDetails, + currentNotActive: 0, + waitingAvgTime:`${waitingAvgTime}`, + }, + }); + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async expertAllSessions(adminIdentity) { + try { + //todo + } catch (err) { + console.log(err); + throw new BaseResponseDTO(err.status, err.response, null); + } + } + + async findAllExperts( + expertUsername: string, + filter: { rate?: string; date?: string; search: string }, + ) { + interface Session { + sessionId: string; + rate: number; + createdAt: [string, string]; + } + + interface Expert { + _id: any; + mobile: string; + username: string; + rate: Session[] | null; + createdAt: Date; + } + + interface ProcessedExpert { + _id: any; + mobile: string; + username: string; + totalSessions: number; + avgRate: number; + first: [string, string] | null; + last: [string, string] | null; + } + + try { + const experts: Expert[] = await this.admin.find({ + role: 'expert', + }); + + // Process filter if provided + const rateFilter = filter?.rate ? Number(filter.rate) : null; + let dateRange: { start: string; end: string } | null = null; + + if (filter?.date) { + const [startDate, endDate] = filter.date.split('-'); + dateRange = { + start: startDate.replace(/\//g, '-'), + end: endDate.replace(/\//g, '-'), + }; + } + + const processedExperts: ProcessedExpert[] = experts.map((expert) => { + const { _id, mobile, username, rate, createdAt } = expert; + + // Filter sessions by date if date filter is provided + let filteredSessions: Session[] = rate ? [...rate] : []; + if (dateRange) { + filteredSessions = filteredSessions.filter((session) => { + const sessionDate = session.createdAt[1]; + return ( + sessionDate >= dateRange!.start && sessionDate <= dateRange!.end + ); + }); + } + + // Calculate totalSessions + const totalSessions: number = filteredSessions.length; + + // Calculate avgRate with proper type handling + let avgRate: number = 0; + if (totalSessions > 0) { + const sum: number = filteredSessions.reduce( + (sum, session) => sum + session.rate, + 0, + ); + avgRate = parseFloat((sum / totalSessions).toFixed(1)); + } + + // Extract first and last dates + const first: [string, string] | null = + totalSessions > 0 ? filteredSessions[0].createdAt : null; + const last: [string, string] | null = + totalSessions > 0 + ? filteredSessions[totalSessions - 1].createdAt + : null; + + return { + _id, + mobile, + username, + totalSessions, + avgRate, + first, + last, + }; + }); + + // Apply rate filter if provided + const filteredResults: ProcessedExpert[] = + rateFilter !== null + ? processedExperts.filter( + (expert) => Math.floor(expert.avgRate) === rateFilter, + ) + : processedExperts; + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', filteredResults); + } catch (err: any) { + console.log(err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.response || 'Internal Server Error', + null, + ); + } + } + + async findExpertsByFilter( + adminIdentity: any, + filter: { + rate?: string; + date?: string; + search?: string; + activityType?: ActivityType; + }, + ) { + try { + if (filter.rate && (filter.date || filter.search || filter.activityType)) { + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, 'Rate filter must be used alone', null); + } + if (filter.search && (filter.rate || filter.date || filter.activityType)) { + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, 'Search filter must be used alone', null); + } + if ((filter.date && !filter.activityType) || (filter.activityType && !filter.date)) { + throw new BaseResponseDTO(HttpStatus.BAD_REQUEST, 'Date filter and activityType must be used together', null); + } + + const pipeline: any[] = []; + const matchStage: any = { role: 'expert' }; + if (filter.search?.trim()) { + const searchRegex = new RegExp(filter.search.trim(), 'i'); + matchStage.$or = [{ name: searchRegex }, { family: searchRegex }, { mobile: searchRegex }]; + } + pipeline.push({ $match: matchStage }); + + // Build session match conditions for the lookup + const sessionMatchConditions: any = { expert: '$$username' }; + + // Add date filter if provided + if (filter.date && filter.activityType) { + const startDate = new Date(TimeHelper.jalaliToISO(filter.date.split('-')[0].trim())); + const endDate = new Date(TimeHelper.jalaliToISO(filter.date.split('-')[1].trim())); + startDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + + const dateField = filter.activityType === 'startActivity' ? 'createdISO' : 'onlineEndDate'; + sessionMatchConditions[dateField] = { $gte: startDate, $lte: endDate }; + } + + // Lookup to get only the minimal session data we need + pipeline.push({ + $lookup: { + from: 'sessions', + let: { username: '$username' }, + pipeline: [ + { $match: { $expr: { $eq: ['$expert', '$$username'] } } }, + { + $project: { + _id: 0, + createdAt: 1, + connectedToExpert: 1, + expertRate: 1, + adminRateToExpert: 1, + createdISO: 1, + onlineEndDate: 1, + }, + }, + ], + as: 'sessions', + }, + }); + + const experts = await this.admin.aggregate(pipeline); + + // Fetch online status from Redis for all experts + const redisClient = this.redisService.getClient(); + const ONLINE_EXPERTS_KEY = 'onlineExperts'; + let onlineExpertMap = new Map(); + + try { + const onlineExpertsEntries = await redisClient.hgetall(ONLINE_EXPERTS_KEY); + Object.entries(onlineExpertsEntries).forEach(([expertId, expertDataStr]) => { + try { + const expertData = JSON.parse(expertDataStr as string); + // Expert is online if isOnline is true OR has active sessions + const isOnline = expertData.isOnline === true || (expertData.activeSessions > 0); + onlineExpertMap.set(expertId, isOnline); + } catch (err) { + // Skip invalid JSON entries + } + }); + } catch (err) { + console.error('Error fetching online experts from Redis:', err); + // Continue with offline status for all if Redis fails + } + + // Process in Node.js with minimal memory footprint + const processedExperts = experts.map(expert => { + const { sessions, ...expertBase } = expert; + + // Calculate total sessions + const totalSessions = sessions.length; + + // Filter sessions based on date/activity if needed + let relevantSessions = sessions; + if (filter.date && filter.activityType) { + const startDate = new Date(TimeHelper.jalaliToISO(filter.date.split('-')[0].trim())); + const endDate = new Date(TimeHelper.jalaliToISO(filter.date.split('-')[1].trim())); + startDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + + const dateField = filter.activityType === 'startActivity' ? 'createdISO' : 'onlineEndDate'; + relevantSessions = sessions.filter(s => { + const sessionDate = new Date(s[dateField]); + return sessionDate >= startDate && sessionDate <= endDate; + }); + } + + // Get connected sessions from relevant ones + const connectedSessions = relevantSessions.filter(s => s.connectedToExpert === true); + + // Calculate average rate (rounded to 2 decimal places) + const validRates = connectedSessions + .map(s => s.expertRate) + .filter(r => r !== null && r !== undefined); + const avgRate = validRates.length > 0 + ? Math.round((validRates.reduce((sum, rate) => sum + rate, 0) / validRates.length) * 100) / 100 + : 0; + + // Calculate admin ratings + const adminRates = connectedSessions + .map(s => s.adminRateToExpert) + .filter(r => r !== null && r !== undefined); + + let adminRateResult: 'like' | 'dislike' | 'equal' | null = null; + const likeCount = adminRates.filter(r => r === true).length; + const dislikeCount = adminRates.filter(r => r === false).length; + + if (adminRates.length > 0) { + if (likeCount > dislikeCount) adminRateResult = 'like'; + else if (dislikeCount > likeCount) adminRateResult = 'dislike'; + else adminRateResult = 'equal'; + } + + // Get first and last session dates + const sortedSessions = sessions + .filter(s => s.createdISO) + .sort((a, b) => new Date(a.createdISO).getTime() - new Date(b.createdISO).getTime()); + const firstSession = sortedSessions.length > 0 ? sortedSessions[0].createdISO : null; + const lastSession = sortedSessions.length > 0 ? sortedSessions[sortedSessions.length - 1].createdISO : null; + + // Get online status from Redis (expertId is the _id as string) + const expertId = expertBase._id.toString(); + const isOnline = onlineExpertMap.get(expertId) || false; + + return { + _id: expertBase._id, + mobile: expertBase.mobile, + name: expertBase.name, + family: expertBase.family, + username: expertBase.username, + totalSessions, + avgRate, + adminRate: adminRateResult, + like: likeCount, + dislike: dislikeCount, + firstSession, + lastSession, + isActive: expertBase.isActive, + isOnline, + }; + }); + + let finalResults = processedExperts; + if (filter.rate?.trim()) { + const rateValue = parseInt(filter.rate.trim(), 10); + if (!isNaN(rateValue)) { + finalResults = processedExperts.filter(expert => Math.floor(expert.avgRate) === rateValue); + } + } + + return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', finalResults, null); + + } catch (err: any) { + console.error('Error in findExpertsByFilter:', err); + throw new BaseResponseDTO( + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + err.message || 'Internal Server Error', + null, + null, + ); + } + } +} diff --git a/src/widget/dto/validate-session.dto.ts b/src/widget/dto/validate-session.dto.ts new file mode 100644 index 0000000..2adb5a0 --- /dev/null +++ b/src/widget/dto/validate-session.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class ValidateSessionDto { + @ApiProperty({ + description: 'Widget hash key', + example: 'wgt_h1x2y3z4', + }) + @IsString() + @IsNotEmpty() + hash: string; + + @ApiProperty({ + description: 'Session nonce', + example: '1234567890abcdef', + }) + @IsString() + @IsNotEmpty() + nonce: string; + + @ApiProperty({ + description: 'Session encryption key', + example: '1234567890abcdef1234567890abcdef', + }) + @IsString() + @IsNotEmpty() + sessionKey: string; +} diff --git a/src/widget/dto/widget-script.dto.ts b/src/widget/dto/widget-script.dto.ts new file mode 100644 index 0000000..c2cb34e --- /dev/null +++ b/src/widget/dto/widget-script.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, Matches } from 'class-validator'; + +export class WidgetScriptDto { + @ApiProperty({ + description: 'Hash key for widget authentication', + example: 'wgt_h1x2y3z4', + }) + @IsString() + @IsNotEmpty() + @Matches(/^wgt_[a-zA-Z0-9]{8}$/, { + message: + 'Invalid widget hash format. Must start with wgt_ followed by 8 alphanumeric characters', + }) + hash: string; +} diff --git a/src/widget/widget.controller.ts b/src/widget/widget.controller.ts new file mode 100644 index 0000000..622db2b --- /dev/null +++ b/src/widget/widget.controller.ts @@ -0,0 +1,59 @@ +import { Controller, Get, Query, Header, BadRequestException, Req, Res } from '@nestjs/common'; +import { Public } from 'src/auth/auth.decorator'; +import { WidgetService } from './widget.service'; + +@Controller('widget') +@Public() +export class WidgetController { + constructor(private readonly widgetService: WidgetService) { } + + @Get('script') + @Header('Content-Type', 'application/javascript') + @Header('Access-Control-Allow-Origin', 'https://staging.hdmplus.ir/, https://si24.ir') + @Header('X-Frame-Options', 'ALLOW-FROM https://staging.hdmplus.ir/ https://si24.ir') + @Header( + 'Content-Security-Policy', + "frame-ancestors 'self' https://staging.hdmplus.ir/ https://si24.ir", + ) + @Header('X-Content-Type-Options', 'nosniff') + getWidgetScript( + @Query('apiKey') apiKey: string, + @Req() req: Request, + ): string { + if (!apiKey) { + throw new BadRequestException('API key is required'); + } + + const isValidApiKey = + Buffer.from(apiKey).length === Buffer.from('si24samanwebsite').length && + Buffer.from(apiKey).compare(Buffer.from('si24samanwebsite')) === 0; + + if (!isValidApiKey) { + throw new BadRequestException('Invalid API key'); + } + + return this.widgetService.generateWidgetScript(); + } + + @Get('iframe') + async getIframeScript( + @Query('apiKey') apiKey: string, + @Req() req: Request, + @Res() res, + ) { + if (!apiKey || apiKey !== 'si24samanwebsite') { + throw new BadRequestException('API key is invalid or missing'); + } + + res.set({ + 'Content-Type': 'application/javascript', + 'Access-Control-Allow-Origin': 'https://staging.hdmplus.ir/, https://si24.ir', + 'X-Frame-Options': 'ALLOW-FROM https://staging.hdmplus.ir/ https://si24.ir', + 'Content-Security-Policy': "frame-ancestors 'self' https://staging.hdmplus.ir/ https://si24.ir", + 'X-Content-Type-Options': 'nosniff', + }); + + const script = this.widgetService.generateWidgetScriptWithoutStyle(); + return res.send(script); + } +} diff --git a/src/widget/widget.module.ts b/src/widget/widget.module.ts new file mode 100644 index 0000000..4d34165 --- /dev/null +++ b/src/widget/widget.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { WidgetController } from './widget.controller'; +import { WidgetService } from './widget.service'; + +@Module({ + imports: [ConfigModule], + controllers: [WidgetController], + providers: [WidgetService], + exports: [WidgetService], +}) +export class WidgetModule {} diff --git a/src/widget/widget.service.ts b/src/widget/widget.service.ts new file mode 100644 index 0000000..0ef8992 --- /dev/null +++ b/src/widget/widget.service.ts @@ -0,0 +1,283 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class WidgetService { + private readonly frontendUrl: string; + constructor(private readonly configService: ConfigService) { + this.frontendUrl = this.configService.get('FRONTEND_URL') || 'https://chatbot.si24.ir'; + // Khahesh mikonam + // 1. az env estefadeh konid + // 2. agar chizi hardcode hast hamahang konid + // -------- az env estefade shude --------- + // c------ chiziam hardcode nist ---- + + } + + generateIframe(): string { + return ` + (function () { + if (window.samanChatbotLoaded || document.getElementById('saman-widget-iframe')) return; + + const iframe = document.createElement('iframe'); + iframe.id = 'saman-widget-iframe'; + iframe.src = '${this.frontendUrl}'; + iframe.style.position = 'fixed'; + iframe.style.top = '0'; + iframe.style.left = '0'; + iframe.style.width = '100vw'; + iframe.style.height = '100vh'; + iframe.style.border = 'none'; + iframe.style.zIndex = '2147483646'; + iframe.style.background = 'white'; + iframe.allow = 'microphone; camera'; + iframe.allowFullscreen = true; + iframe.loading = 'lazy'; + iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups'; + + document.body.appendChild(iframe); + window.samanChatbotLoaded = true; + })(); + `; + } + generateWidgetScript(): string { + return ` + (function () { + if (window.samanChatbotLoaded) return; + window.samanChatbotLoaded = true; + + const style = document.createElement('style'); + style.textContent = \` + #saman-widget-btn { + position: fixed; + bottom: 16px; + right: 16px; + width: 45px; + height: 45px; + border-radius: 50%; + border: none; + background: linear-gradient(135deg,#0062ff,#007bff); + box-shadow: 0 4px 15px rgba(0,0,0,0.2); + z-index: 2147483647; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: width 0.3s ease, height 0.3s ease; + } + #saman-widget-btn img { width: 22px; height: 22px; } + #saman-widget-iframe { + position: fixed; + bottom: 80px; + right: 16px; + width: 380px; + height: 710px; + border: none; + border-radius: 12px; + display: none; + z-index: 2147483646; + box-shadow: 0 10px 40px rgba(0,0,0,0.2); + } + #saman-widget-iframe.visible { display: block; } + @media (max-width: 768px) { + #saman-widget-iframe { + top: 0; left: 0; right: 0; bottom: 0; + width: 100vw; height: 100vh; + border-radius: 0; + } + #saman-close-btn { + position: fixed; + top: 12px; left: 12px; + width: 44px; height: 44px; + border-radius: 50%; + background: rgba(0,0,0,0.6); + color: #fff; + border: none; + display: flex; + align-items: center; + justify-content: center; + z-index: 2147483647; + font-size: 20px; + } + } + \`; + document.head.appendChild(style); + + const btn = document.createElement('button'); + btn.id = 'saman-widget-btn'; + btn.innerHTML = 'chat'; + document.body.appendChild(btn); + + const iframe = document.createElement('iframe'); + iframe.id = 'saman-widget-iframe'; + iframe.src = '${this.frontendUrl}'; + iframe.allow = 'microphone; camera; storage-access-api'; + iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-storage-access-by-user-activation'; + iframe.setAttribute('loading', 'lazy'); + iframe.setAttribute('credentialless', 'false'); + iframe.setAttribute('allow', 'storage-access-api *'); + document.body.appendChild(iframe); + + let closeBtn; + + function openWidget() { + iframe.classList.add('visible'); + if (window.innerWidth < 768) { + iframe.style.top = '0'; + iframe.style.left = '0'; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.borderRadius = '0'; + + closeBtn = document.createElement('button'); + closeBtn.id = 'saman-close-btn'; + closeBtn.innerHTML = '✕'; + document.body.appendChild(closeBtn); + closeBtn.onclick = closeWidget; + + document.body.style.overflow = 'hidden'; + document.documentElement.style.overflow = 'hidden'; + btn.style.setProperty('visibility', 'hidden', 'important'); + btn.style.setProperty('opacity', '0', 'important'); + btn.style.setProperty('pointer-events', 'none', 'important'); + + } + + setTimeout(() => { + if (iframe.contentWindow && + 'requestStorageAccess' in document && + typeof document.requestStorageAccess === 'function') { + iframe.contentWindow.postMessage({ + type: 'REQUEST_STORAGE_ACCESS' + }, '${this.frontendUrl}'); + } + }, 1000); + } + + function closeWidget() { + iframe.classList.remove('visible'); + if (closeBtn) { + closeBtn.remove(); + closeBtn = null; + } + document.body.style.overflow = ''; + document.documentElement.style.overflow = ''; + + // Show the widget button again when closing + btn.style.setProperty('visibility', 'visible', 'important'); + btn.style.setProperty('opacity', '1', 'important'); + btn.style.setProperty('pointer-events', 'auto', 'important'); +} + + btn.addEventListener('click', function () { + if (iframe.classList.contains('visible')) closeWidget(); + else openWidget(); + }); + + const allowedOrigins = ['https://chatbot.si24.ir', 'https://staging.hdmplus.ir']; + + window.addEventListener('message', function(event) { + if (!allowedOrigins.includes(event.origin)) return; + + if (event.data.type === 'REQUEST_DATA') { + iframe.contentWindow.postMessage({ + type: 'RESPONSE_DATA', + payload: event.data.payload + }, event.origin); + } + + if (event.data.type === 'REDIRECT_REQUEST') { + window.top.location.href = event.data.url; + } + + if (event.data.type === 'REQUEST_STORAGE_ACCESS' && + 'requestStorageAccess' in document && + typeof document.requestStorageAccess === 'function') { + document.requestStorageAccess().then(() => { + iframe.contentWindow.postMessage({ + type: 'STORAGE_ACCESS_GRANTED' + }, event.origin); + }).catch(() => { + iframe.contentWindow.postMessage({ + type: 'STORAGE_ACCESS_DENIED' + }, event.origin); + }); + } + }); + + iframe.addEventListener('load', function() { + setTimeout(() => { + iframe.contentWindow.postMessage({ + type: 'IFRAME_LOADED' + }, '${this.frontendUrl}'); + }, 500); + }); + })(); +`; + } + generateWidgetScriptWithoutStyle(): string { + return ` + (function() { + if (window.samanChatbotLoaded) return; + window.samanChatbotLoaded = true; + + const btn = document.createElement('button'); + btn.style.cssText = "position:fixed;bottom:16px;right:16px;width:45px;height:45px;border-radius:50%;border:none;background:linear-gradient(135deg,#0062ff,#007bff);cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2147483647;transition:width 0.3s ease,height 0.3s ease"; + btn.innerHTML = ''; + document.body.appendChild(btn); + + const iframe = document.createElement('iframe'); + iframe.src = '${this.frontendUrl}'; + iframe.allow = 'microphone;camera'; + iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups'; + iframe.style.cssText = "position:fixed;bottom:80px;right:16px;width:380px;height:600px;border:none;border-radius:12px;display:none;z-index:2147483646;box-shadow:0 10px 40px rgba(0,0,0,0.2)"; + document.body.appendChild(iframe); + + let closeBtn; + + function openWidget() { + iframe.style.display = 'block'; + if (window.innerWidth < 768) { + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.borderRadius = '0'; + + closeBtn = document.createElement('button'); + closeBtn.innerHTML = '✕'; + closeBtn.style.cssText = "position:fixed;top:12px;left:12px;width:44px;height:44px;border-radius:50%;background:rgba(0,0,0,0.6);color:#fff;border:none;display:flex;align-items:center;justify-content:center;z-index:2147483647;font-size:20px"; + closeBtn.onclick = closeWidget; + document.body.appendChild(closeBtn); + + document.body.style.overflow = 'hidden'; + document.documentElement.style.overflow = 'hidden'; + + btn.style.setProperty('visibility', 'hidden', 'important'); + btn.style.setProperty('opacity', '0', 'important'); + btn.style.setProperty('pointer-events', 'none', 'important'); + } + } + + function closeWidget() { + iframe.style.display = 'none'; + if (closeBtn) { closeBtn.remove(); closeBtn = null; } + document.body.style.overflow = ''; + document.documentElement.style.overflow = ''; + btn.style.setProperty('visibility', 'visible', 'important'); + btn.style.setProperty('opacity', '1', 'important'); + btn.style.setProperty('pointer-events', 'auto', 'important'); + } + + btn.addEventListener('click', function() { + if (iframe.style.display === 'block') closeWidget(); + else openWidget(); + }); + })(); + `; + } + + + + getScript(): string { + return ``; + } +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..f6c7338 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,41 @@ +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +module.exports = { + entry: { + main: './src/index.tsx', + widget: './src/widget/widget-entry.tsx' + }, + output: { + path: path.resolve(__dirname, 'dist'), + filename: '[name].js', + publicPath: '/' + }, + module: { + rules: [ + { + test: /\.(ts|tsx)$/, + use: 'ts-loader', + exclude: /node_modules/ + }, + { + test: /\.css$/, + use: ['style-loader', 'css-loader'] + } + ] + }, + resolve: { + extensions: ['.tsx', '.ts', '.js'] + }, + plugins: [ + new HtmlWebpackPlugin({ + template: './public/index.html', + chunks: ['main'] + }), + new HtmlWebpackPlugin({ + filename: 'widget.html', + template: './public/widget.html', + chunks: ['widget'] + }) + ] +}; \ No newline at end of file