Compare commits

...

120 Commits

Author SHA1 Message Date
Vitalik af05c70a57 fix: calls list 1 month ago
Vitalik 138f5805a1 FIX | Auth attemption clearing 1 month ago
Vitalik 7c058862ca add log 2 months ago
Vitalik aba40fbe17 add logs 2 months ago
Vitalik dcdb0a0cbd fix: finish call 2 months ago
Vitalik 3e3914fc78 fix: answer call 2 months ago
Vitalik ced8a853a5 fix: finish call payload 2 months ago
Vitalik e490da2f01 feature: cancel call 2 months ago
Vitalik e35e2737ad feature: get call 2 months ago
Vitalik 944dc81d34 feature: callsv2 2 months ago
Vitalik 09f374d539 fix: list 2 months ago
Vitalik ccc7936906 fix: save images and video as is 2 months ago
Vitalik 281edaa954 fix: hide new task 2 months ago
Vitalik f40fb39435 fix: secret mod notifications 3 months ago
Vitalik b4f7f2d3a5 fix: turn mod 3 months ago
Vitalik 6778a46c5f fix : reload app 3 months ago
Vitalik 268dc0323e fix : reload app 3 months ago
Vitalik f770fccf0c fix: ws service 3 months ago
Vitalik 9339ca9b4f fix: real-time fix 3 months ago
Vitalik 9d531d854c feature | send socket about turn secret mod 3 months ago
Vitalik 3dafeaae31 feat: docker-compose prod 3 months ago
Vitalik d2853d78f9 fix: verions listeners 3 months ago
Vitalik 658d0ea100 fix: remove logs 3 months ago
Vitalik 686a41164e fix: events listeners 3 months ago
Vitalik ac9e398fd9 fix: log 3 months ago
Vitalik fe4132312d fix: sessions blocks 3 months ago
Vitalik bb5de0262a fix: sessions blocks 3 months ago
Vitalik 183234bb45 fix: allocate chat after creating 3 months ago
Vitalik 7d4324f128 fix: chats 3 months ago
Vitalik 85ac99e8f0 fix: chats service 3 months ago
Vitalik d022c843df fix: add logs 3 months ago
Vitalik d5e0ee9fa8 fix: add logs 3 months ago
Vitalik 04b51aa61a fix: create chat 3 months ago
Vitalik c3545c2771 chore | update docker-compose stage 3 months ago
Vitalik c4400cbcdb feature: hidden tasks, ips, logs 3 months ago
Vitalik 3fe40653ba FIX | Start call in android 3 months ago
Vitalik 1dd268d808 FIX | send newmessages notification 3 months ago
Vitalik f59a924ad0 FEATURE | Hide comments from hidden users 4 months ago
Vitalik e5403b78ba FEATURE | Hide notifications from secret users 4 months ago
Vitalik 7cdedf3de7 FIX | Hide calls 4 months ago
Vitalik 84ec35551e FEATURE | Hide chats if member hidden 4 months ago
Vitalik aec27fb0ba FIX | ExpiredAt for new call 4 months ago
Vitalik 6fec0ae2d1 FIX | Send voip notification 4 months ago
Vitalik 51c1b6bd42 FIX | Don't send voip about cancel 2 4 months ago
Vitalik 51b904e2f9 FIX | Don't send voip about cancel 4 months ago
Vitalik 46cf94464d CHANGE | Added expiredAt to call 4 months ago
Vitalik 8a4a56a714 fix | send firebase api 4 months ago
Vitalik e851b601f9 log | call 4 months ago
Vitalik a81a6ebc06 FIX | Log console 4 months ago
Vitalik c7c6489571 FIX | Reject by token 4 months ago
Vitalik 5e0a266de3 FIX | Cancel call 4 months ago
Vitalik 23abf6a2e3 FEATURE | Reject call by token 4 months ago
Vitalik 77003dfa14 FIX | Start call 4 months ago
Vitalik c665ba71b9 FEATURE | optimize chats list 4 months ago
Vitalik 46b1721bdf FIX | Push firebase call 4 months ago
Vitalik 7c35fbf421 FEATURE | Test calls endpoints 4 months ago
Vitalik 51fcf2cf0f FIX | Sockets 4 months ago
Vitalik a310e108ed FEATURE | Hide secret users 4 months ago
Vitalik 4621d3f631 FEATURE | Secret reset module 4 months ago
Vitalik 7a2b0b48dd FEATURE | Get secret users list 4 months ago
Vitalik fa343d66f1 FEATURE | Hide/reveal users 4 months ago
Vitalik 81c574bd5e FEATURE | Secret mod module 4 months ago
Vitalik 77e8c7a680 CHORE | Console 5 months ago
Vitalik 3a81b2caf5 FEATURE | Multi forward messages 5 months ago
Vitalik dcc4c61fcf CHORE | Add cache for chats 5 months ago
Vitalik bcf1813fbd CHANGE | Token life time env param 6 months ago
Vitalik 1dd017c38f CHANGE | Remove sentry profilling 6 months ago
Vitalik 7392b12fc3 Merge branch 'stage' of gitlab.work-jetup.site:task-me/api-rws into calls 6 months ago
Yevhen Romanenko 5446f10680 FEATURE | setup sentry.io issues handler (#14) 6 months ago
Vitalik Yatsenko fab8c4547d calls (#13) 7 months ago
Vitalik 5b2e0ed921 fix | version 7 months ago
Yevhen Romanenko 96c6ac79fe BUGFIX | remove director permissions if we send empty factory director (BANK-1099) (#12) 7 months ago
Vitalik 808a279932 FIX | Calls notifications 7 months ago
Oksana Stepanenko 1134f4de6e CHANGE | Message unique key (#11) 7 months ago
Vitalik 135a3ddea5 FEATURE | Clear notificaton sdevices after logout 7 months ago
Vitalik f1f44e0815 FEATURE | Update gitgnore 7 months ago
Vitalik fcfbabccd0 FEATURE | Slient pushes, negotiation events 7 months ago
Vitalik 19ac028e9b FEATURE | Voip dev token 8 months ago
Vitalik d3e14a2381 fIX | Calls 8 months ago
Vitalik 894a6093c9 Merge branch 'stage' of gitlab.work-jetup.site:task-me/api-rws into calls 8 months ago
Vitalik e1d49b343b FEATURE | Apns module 8 months ago
Vitalik 981151b0c3 CHANGE | Calls 8 months ago
Yevhen Romanenko 1b8d07921d FEATURE | add push notifications for close to end tasks (#10) 8 months ago
Vitalik 015f79250b Merge branch 'stage' of gitlab.work-jetup.site:task-me/api-rws into stage 8 months ago
Vitalik b26879f82e FEATURE | Voip notifications devvices 8 months ago
Oksana Stepanenko 329a6b2fa7 FEATURE | Send notification to all users (#9) 8 months ago
Vitalik 14500bf678 FIX | Recovery pass 8 months ago
Vitalik 87808ff03a FIX | Recovery pass 8 months ago
Vitalik a6f932a492 FIX | Deblock account after recover password 8 months ago
Vitalik 7e7baf6afb CHANGE | CallStatus 8 months ago
Vitalik aa60f8e468 system 8 months ago
Vitalik 93c6d9a70a FIX | Cancel cal 8 months ago
Vitalik 03abeda566 FIX | Rest calls dto 8 months ago
Vitalik ee8136382c fix | compile 8 months ago
Vitalik f906d87a3f fix | compile 8 months ago
Vitalik 24168af92a FEATURE | Add calls route 8 months ago
Vitalik 4860665b7b FIX | Ice candidates 9 months ago
Vitalik 4f13623ea7 Merge branch 'stage' of gitlab.work-jetup.site:task-me/api-rws into stage 9 months ago
Vitalik 0b1dc3c4e5 FIX | Calls 9 months ago
Yevhen Romanenko 0d85bd289a BUGFIX | remove doubling push notifications about expired tasks (BANK-1159) (#8) 9 months ago
Vitalik c9ac276d4f FIX | Admin auth imports 9 months ago
Vitalik 32b48344d6 FIX | Unblock ip after recovery pass 9 months ago
Vitalik 3e86af8b89 fIX 9 months ago
Vitalik aace04211c CHANGE | Drop count after ip was blocked 9 months ago
Vitalik f7b8e6165c FEATURE | Send messaget to email about tasks deadlines 9 months ago
Vitalik 5225a3625b FIX | Add custom notificationgroup 9 months ago
Vitalik b747de0376 FIX | Send custom notification 9 months ago
Vitalik ddbc591ff4 FIX | Send emails 9 months ago
Vitalik f158bcc58f Merge branch 'stage' of gitlab.work-jetup.site:task-me/api-rws into stage 9 months ago
Vitalik cf6d9997c1 CHANGE | Add author to custom notification 9 months ago
Oksana Stepanenko 835ea8c0eb FIX | Send recovery password confirmation code | admin-password-recovery.service.ts (#7) 9 months ago
Vitalik bceac154bf FIX | events bug 9 months ago
Oksana Stepanenko 217ce8ebcc tasks/hard-delete (#6) 9 months ago
YaroslavBerkuta 9fb407db95 fix get chats user list (#5) 9 months ago
Vitalik cc6413929a Merge branch 'stage' of gitlab.work-jetup.site:task-me/api-rws into stage 9 months ago
Vitalik c880ed2713 FEATURE | Notifications about tasks deadlines 9 months ago
Vitalik Yatsenko ea3544f2fa FEATURE/new system to block ip (#4) 9 months ago
Vitalik 752d330bf7 Merge branch 'stage' of gitlab.work-jetup.site:task-me/api-rws into ip-block 9 months ago
Vitalik 96eecc4263 FEATURE | Block ip by new system 9 months ago
Vitalik 0e08a9b801 FEATURE | Send notifications from admin 9 months ago
  1. 25
      .env.example
  2. 105
      .env.save
  3. 6
      .gitignore
  4. 3
      Dockerfile
  5. 5
      deploy/dev.sh
  6. 58
      docker-compose.local.yml
  7. 40
      docker-compose.prod.yml
  8. 38
      docker-compose.stage.yml
  9. 42
      examples/api.rwsbank.com.ua.nginx-example
  10. 20
      examples/fs-tasks.rwsbank.com.ua.nginx-example
  11. 58
      examples/tasks.rwsbank.com.ua.nginx-example
  12. 25951
      package-lock.json
  13. 13
      package.json
  14. 33
      src/app.module.ts
  15. 8
      src/config/entities.config.ts
  16. 62
      src/config/index.ts
  17. 1
      src/core/consts/index.ts
  18. 6
      src/core/dto/users.dto.ts
  19. 58
      src/core/enums/events.enum.ts
  20. 3
      src/core/interfaces/domain-exception.interfaces.ts
  21. 22
      src/core/namespaces/chats.namespace.ts
  22. 7
      src/core/namespaces/ips.namespace.ts
  23. 66
      src/core/namespaces/notifications.namespace.ts
  24. 34
      src/core/namespaces/sessions.namespace.ts
  25. 9
      src/core/namespaces/tasks.namespace.ts
  26. 2
      src/core/namespaces/users.namespace.ts
  27. 2
      src/core/namespaces/versions.namespace.ts
  28. 3
      src/core/namespaces/ws.namespace.ts
  29. 2
      src/domain/activities/activities.module.ts
  30. 50
      src/domain/activities/services/activity-events.service.ts
  31. 3
      src/domain/activities/services/index.ts
  32. 38
      src/domain/calls/calls.module.ts
  33. 162
      src/domain/calls/controllers/calls.controller.ts
  34. 72
      src/domain/calls/core/answer-call.ts
  35. 103
      src/domain/calls/core/call-action.ts
  36. 7
      src/domain/calls/core/call-token.ts
  37. 88
      src/domain/calls/core/finish-call.ts
  38. 73
      src/domain/calls/core/on-connected.ts
  39. 86
      src/domain/calls/core/ready-to-connect.ts
  40. 30
      src/domain/calls/core/send-ice-candidates.ts
  41. 32
      src/domain/calls/core/send-rtc-session.ts
  42. 144
      src/domain/calls/core/start-call.ts
  43. 35
      src/domain/calls/core/update-media-settings.ts
  44. 3
      src/domain/calls/dto/end-call.dto.ts
  45. 14
      src/domain/calls/dto/reject-call.dto.ts
  46. 35
      src/domain/calls/entities/call-log.entity.ts
  47. 45
      src/domain/calls/entities/call-member.entity.ts
  48. 46
      src/domain/calls/entities/call.entity.ts
  49. 7
      src/domain/calls/entities/index.ts
  50. 10
      src/domain/calls/exceptions/access-call-wrong.exception.ts
  51. 10
      src/domain/calls/exceptions/call-alerady-finished.exception.ts
  52. 10
      src/domain/calls/exceptions/call-already-ready-to-connect.exception.ts
  53. 10
      src/domain/calls/exceptions/call-answered.exception.ts
  54. 10
      src/domain/calls/exceptions/call-not-found.exception.ts
  55. 8
      src/domain/calls/exceptions/index.ts
  56. 10
      src/domain/calls/exceptions/initiator-in-call.exception.ts
  57. 10
      src/domain/calls/exceptions/target-user-in-call.exception.ts
  58. 10
      src/domain/calls/exceptions/wrong-device.exception.ts
  59. 89
      src/domain/calls/services/calls-actions-factory.ts
  60. 59
      src/domain/calls/services/calls-read.service.ts
  61. 88
      src/domain/calls/services/calls.service.ts
  62. 150
      src/domain/calls/typing/index.ts
  63. 178
      src/domain/chats/actions/get-chats-list.action.ts
  64. 15
      src/domain/chats/chats.module.ts
  65. 3
      src/domain/chats/entities/chat-message.entity.ts
  66. 15
      src/domain/chats/listeners/index.ts
  67. 29
      src/domain/chats/listeners/on-chat-message-view.listener.ts
  68. 15
      src/domain/chats/listeners/on-chat-new-message.listener.ts
  69. 15
      src/domain/chats/listeners/on-chat-update.listener.ts
  70. 47
      src/domain/chats/listeners/on-new-chat.listener.ts
  71. 43
      src/domain/chats/listeners/on-read-chat.listener.ts
  72. 19
      src/domain/chats/listeners/on-secret-mod-turn.listener.ts
  73. 15
      src/domain/chats/listeners/on-secret-records-changed.listener.ts
  74. 73
      src/domain/chats/services/chats-accessory.service.ts
  75. 81
      src/domain/chats/services/chats-cache.service.ts
  76. 43
      src/domain/chats/services/chats-events.service.ts
  77. 12
      src/domain/chats/services/chats-fixed.service.ts
  78. 126
      src/domain/chats/services/chats-forward-messages.service.ts
  79. 62
      src/domain/chats/services/chats-messages.service.ts
  80. 72
      src/domain/chats/services/chats-secret.service.ts
  81. 46
      src/domain/chats/services/chats.service.ts
  82. 5
      src/domain/chats/services/index.ts
  83. 2
      src/domain/comments/comments.module.ts
  84. 21
      src/domain/comments/services/comments.service.ts
  85. 49
      src/domain/factories/tests/unit/factories-validator.service.spec.ts
  86. 77
      src/domain/factories/tests/unit/factory-trees.service.spec.ts
  87. 1
      src/domain/index.ts
  88. 18
      src/domain/ips/entities/ip.entity.ts
  89. 2
      src/domain/ips/ips.module.ts
  90. 1
      src/domain/ips/services/ips-lists.service.ts
  91. 18
      src/domain/ips/services/ips.service.ts
  92. 1
      src/domain/logger/filter/index.ts
  93. 43
      src/domain/logger/filter/logger-exception.filter.ts
  94. 3
      src/domain/logger/index.ts
  95. 9
      src/domain/logger/logger.module.ts
  96. 1
      src/domain/logger/services/index.ts
  97. 46
      src/domain/logger/services/logger.service.ts
  98. 1
      src/domain/logger/typing/enums/index.ts
  99. 15
      src/domain/logger/typing/enums/logger-messages.enum.ts
  100. 1
      src/domain/logger/typing/index.ts
  101. Some files were not shown because too many files have changed in this diff Show More

25
.env.example

@ -20,7 +20,7 @@ REDIS_PORT=6379 @@ -20,7 +20,7 @@ REDIS_PORT=6379
LOCAL_HASH_SALT=SAD130DL23DL23DL02
CHAT_MESSAGES_CRYPT_SALT=3DLJ3JF933934OEWJ390VJ85
# MINIO
# MINIO
MINIO_SECRET_KEY=K2FCDDS4JJ8DHUYa8ba7550cfff942b08
MINIO_ACCESS_KEY=V42FCG42DK2FDDS4JJ8DHUYG
MINIO_HOST=taskme-minio
@ -47,7 +47,7 @@ MAILER_SECURE= @@ -47,7 +47,7 @@ MAILER_SECURE=
MAILER_LOGIN=
MAILER_PASSWORD=
# JWT
# JWT
JWT_KEY=ASD12JD12M0,9-D1S21
JWT_PAYLOAD_KEY=1829JS12,W0-L12,
@ -78,4 +78,23 @@ ALLOWED_TASK_FILES_TYPES='jpeg,jpg,png,svg,webp,tiff,pdf,txt,doc,docx,xls,xlsx' @@ -78,4 +78,23 @@ ALLOWED_TASK_FILES_TYPES='jpeg,jpg,png,svg,webp,tiff,pdf,txt,doc,docx,xls,xlsx'
# IN KB
ALLOWED_CHAT_FILES_SIZE=50000
# IN KB
ALLOWED_CHAT_VIDEOS_SIZE=100000
ALLOWED_CHAT_VIDEOS_SIZE=100000
# BLOCK IPS OPTIONS
MAX_ATTEMPS_BY_IP=50
MAX_ATTEMPS_BY_FINGERPRINT=5
MAX_ATTEMPS_BY_TIME=10
MAX_GAP_FOR_TIME_ATTEMPS=3000 # in miliseconds
# TASKS OPTIONS
# the number of tasks that are processed at one time
MAX_TASKS_BATCH_SIZE=20
# '%' of total task duration until deadline
PERCENT_OF_TASK_TOTAL_DURATION=20
# Sentry logger setup
SENTRY_ENVIROMENT=develop
SENTRY_DNS=

105
.env.save

@ -1,51 +1,112 @@ @@ -1,51 +1,112 @@
APP_VERSION_IOS=2.2
APP_VERSION_ANDROID=2.2
AUTO_SEED_ENABLED=true
TEST_ENV=test
SYSTEMS_USERS_PHONES=380980717970
DATABASE_DB=taskme
DATABASE_USER=taskme
DATABASE_PASS=taskme
DATABASE_HOST=taskme-postgres
DATABASE_PORT=5432
DATABASE_HOST=localhost
DATABASE_PORT=5001
# OLD DATABASE
OLD_DATABASE_DB=false
AUTO_DUMP_OLD_DB_ENABLED=false
# REDIS
REDIS_PASS=12345
REDIS_HOST=taskme-redis
REDIS_PORT=6379
REDIS_HOST=localhost
REDIS_PORT=5002
LOCAL_HASH_SALT=SAD130DL23DL23DL02
CHAT_MESSAGES_CRYPT_SALT=912DK2M1D0912-LS,12IDJ32MUFDM2,1D0
# MINIO
MINIO_SECRET_KEY=K2FCDDS4JJ8DHUYa8ba7550cfff942b08
MINIO_ACCESS_KEY=V42FCG42DK2FDDS4JJ8DHUYG
MINIO_HOST=taskme-minio
MINIO_PORT=9000
MINIO_URL_PREFIX=http://localhost
MINIO_HOST=localhost
MINIO_PORT=5003
MINIO_URL_PREFIX=http://taskme-fs.work-jetup.site
MINIO_FILES_URL_PREFIX=http://taskme-fs.work-jetup.site
MINIO_BUCKET=rws
MINIO_PRIVATE_BUCKET=rwsprivate
# IMGPROXY
IMGPROXY_KEY=d37e5654e66c027dc66937aaa0faf7c76445d730d797f91edcaaddf4648fb971c1cf7cd5135d36ed92a12992eee85df1534b6f243029e31270bf86457a21c920
IMGPROXY_SALT=e5516246fd072573ad7a9eb2339a18c417000cbc1ce5a13d3752673d77a746089997c423888cd29b50271c29b9f11b5fdb4917eb5ca53fe9f87b2ecd635bde17
IMGPROXY_BASE_URL=http://taskme-imgs.work-jetup.site
# SMS
SMS_TEST_MODE=true
SMS_ALPHASMS_API_KEY=ea1503353d709f10b06c0962fa1f8e4e13614489
SMS_ALPHASMS_FROM=Task me
#JWT
JWT_KEY=ASD12JD12M0,9-D1S21
JWT_PAYLOAD_KEY=1829JS12,W0-L12,
JWT_LIFE_TIME=10s
AUTO_SEED_ENABLED=true
# MAILER
MAILER_PROTOCOL=smtp
MAILER_TEST_MODE=true
MAILER_DOMAIN=mail.ubg.ua
MAILER_PORT=
MAILER_SECURE=
MAILER_LOGIN=
MAILER_PASSWORD=
#JWT
JWT_KEY=ASD12JD12M0,9-D1S21
JWT_PAYLOAD_KEY=1829JS12,W0-L12,
MAILER_PORT=465
MAILER_SECURE=true
MAILER_LOGIN=taskme@rwsbank.com.ua
MAILER_PASSWORD=BsZH@Rgxtfz4pk
# PUSH
PUSH_NOTIFICATIONS_SERVICE=one-signal
PUSH_APP_ID=ae1524a2-a4b5-4640-9f35-efa8f2cc2c78
PUSH_REST_API_KEY=MjI2MWEyYzctYzFhZC00YjQzLWE2NzUtYjg1ZDQ3NDcwODVh
PUSH_APP_ID=5e1a5e18-33e5-4ed3-8423-45b1abc354c6
PUSH_REST_API_KEY=YjVmMjhhYzYtYmQzNy00MzMwLTk2ODEtNzFjYTIwN2NmYjQy
PUSH_NOTIFICATION_ANDROID_CALLS_CHANNEL_ID=ffc845a6-4a7e-410e-95e5-ac32bb404448
VOIP_PUSH_NOTIFICATIONS_APP_ID=42e07e75-a735-4351-af0b-912a47831a5a
VOIP_PUSH_NOTIFICATIONS_REST_API_KEY=YmUyZDM5OTktYTRlYy00Y2Y5LThhZTItNjg1MDNmOWJlZDdm
# IN PX
ALLOWED_AVATAR_WIDTH=200
# IN PX
ALLOWED_AVATAR_HEIGHT=200
# IN KB
ALLOWED_AVATAR_SIZE=500
# ONLY IMAGE FILES TYPES DIVIDED BY COMMA
ALLOWED_AVATAR_TYPES='jpg,svg,png,webp'
# IN KB
ALLOWED_TASK_FILES_SIZE=100000
# ANY FILES TYPES DIVIDED BY COMMA
ALLOWED_TASK_FILES_TYPES='jpeg,jpg,png,svg,webp,tiff,pdf,txt,doc,docx,xls,xlsx'
# IN KB
ALLOWED_CHAT_FILES_SIZE=50000
# IN KB
ALLOWED_CHAT_VIDEOS_SIZE=100000
# BLOCK IPS OPTIONS
MAX_ATTEMPS_BY_IP=50
MAX_ATTEMPS_BY_FINGERPRINT=5
MAX_ATTEMPS_BY_TIME=10
MAX_GAP_FOR_TIME_ATTEMPS=3000 # in miliseconds
CRON_ENABLED=false
APNS_VOIP_CERT_PART=/var/www/certs/rws/certs/voip-base.p12
APNS_VOIP_CERT_PASS=12345678
IOS_BUNDLE_ID=com.app.taskme.voip
APNS_VOIP_IS_DEV=true
MAX_TASKS_BATCH_SIZE=20
PERCENT_OF_TASK_TOTAL_DURATION=20
FIREBASE_CONFIG_JFON_PARH=/var/www/certs/taskme-dae7e-firebase-adminsdk-du13i-f49f0a0624.json
SENTRY_DNS=https://23cf7a02dc8578b656e4ba2e8b6f397a@o402114.ingest.us.sentry.io/450722074669875223
SENTRY_ENVIROMENT=local
SUPERADMIN_KEYS=1234567890

6
.gitignore vendored

@ -41,3 +41,9 @@ lerna-debug.log* @@ -41,3 +41,9 @@ lerna-debug.log*
./taskme
./taskme/*
.vscode/settings.json
*.pm12
**/secrets
secrets/**

3
Dockerfile

@ -1,5 +1,4 @@ @@ -1,5 +1,4 @@
FROM node:14.0.0
FROM node:16.20.2
RUN apt-get update
RUN apt-get install -y build-essential

5
deploy/dev.sh

@ -1,9 +1,8 @@ @@ -1,9 +1,8 @@
ssh root@185.69.154.136 "
cd /home/rws/api-rws &&
cd /var/www/rws/api-rws &&
git reset --hard &&
git pull origin stage &&
npm run build &&
docker-compose -f docker-compose.stage.yml up -d &&
docker restart api-rws_taskme-api_1
pm2 restart all
"

58
docker-compose.local.yml

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
version: '3'
services:
taskme-postgres:
image: postgres:11
restart: always
ports:
- 3303:5432
environment:
POSTGRES_PASSWORD: ${DATABASE_PASS}
POSTGRES_USER: ${DATABASE_USER}
POSTGRES_DB: ${DATABASE_DB}
taskme-redis:
image: 'redis:4-alpine'
command: redis-server --requirepass ${REDIS_PASS}
ports:
- '6379:6379'
taskme-minio:
hostname: taskme-minio
image: minio/minio:RELEASE.2021-09-18T18-09-59Z
container_name: taskme-minio
volumes:
- './taskme/data/:/data'
- './taskme/config:/root/.minio'
ports:
- 5003:9000
- 5004:9001
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
command: server --console-address ":9001" /data
taskme-createbuckets:
image: minio/mc
depends_on:
- taskme-minio
entrypoint: >
/bin/sh -c "
sleep 10;
/usr/bin/mc config host add data http://${MINIO_HOST}:${MINIO_PORT} ${MINIO_ACCESS_KEY} ${MINIO_SECRET_KEY};
/usr/bin/mc mb data/${MINIO_BUCKET};
/usr/bin/mc policy set public data/${MINIO_BUCKET};
exit 0;
"
taskme-imgproxy:
image: 'darthsim/imgproxy:latest'
ports:
- '5005:8080'
environment:
IMGPROXY_KEY: ${IMGPROXY_KEY}
IMGPROXY_SALT: ${IMGPROXY_SALT}
IMGPROXY_MAX_SRC_FILE_SIZE: 10485760

40
docker-compose.prod.yml

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
version: '3'
services:
taskme-redis:
image: redis:latest
restart: always
ports:
- '6379:6379'
volumes:
- /var/www/redis:/root/redis
- /var/www/redisredis.conf:/usr/local/etc/redis/redis.conf
environment:
- REDIS_PASSWORD=${REDIS_PASS}
- REDIS_PORT=6379
- REDIS_DATABASES=16
taskme-minio:
hostname: taskme-minio
image: minio/minio:RELEASE.2021-09-18T18-09-59Z
container_name: taskme-minio
volumes:
- './taskme/data/:/data'
- './taskme/config:/root/.minio'
ports:
- 9000:9000
- 9001:9001
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
command: server --console-address ":9001" /data
taskme-imgproxy:
image: 'darthsim/imgproxy:latest'
ports:
- '5005:8080'
environment:
IMGPROXY_KEY: ${IMGPROXY_KEY}
IMGPROXY_SALT: ${IMGPROXY_SALT}
IMGPROXY_MAX_SRC_FILE_SIZE: 10485760

38
docker-compose.stage.yml

@ -1,25 +1,25 @@ @@ -1,25 +1,25 @@
version: '3'
services:
taskme-api:
build:
context: ./
dockerfile: Dockerfile
expose:
- 5000
ports:
- 5000:3000
depends_on:
- taskme-postgres
- taskme-redis
- taskme-minio
links:
- taskme-postgres
- taskme-redis
- taskme-minio
volumes:
- ./:/home/node/app
command: npm run start
# taskme-api:
# build:
# context: ./
# dockerfile: Dockerfile
# expose:
# - 5000
# ports:
# - 5000:3000
# depends_on:
# - taskme-postgres
# - taskme-redis
# - taskme-minio
# links:
# - taskme-postgres
# - taskme-redis
# - taskme-minio
# volumes:
# - ./:/home/node/app
# command: npm run start
taskme-postgres:
image: postgres:11

42
examples/api.rwsbank.com.ua.nginx-example

@ -88,4 +88,46 @@ server { @@ -88,4 +88,46 @@ server {
try_files $uri =404;
}
}
server {
server_name prod2-rws-api.work-jetup.site;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
add_header 'Access-Control-Allow-Methods' 'GET,POST,OPTIONS,PUT,DELETE,PATCH';
gzip on;
gzip_types text/plain application/xml text/css application/javascript;
gzip_min_length 1000;
location / {
client_max_body_size 100M;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
proxy_read_timeout 1m;
proxy_connect_timeout 1m;
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
listen 80;
listen [::]:80;
}

20
examples/fs-tasks.rwsbank.com.ua.nginx-example

@ -1,34 +1,26 @@ @@ -1,34 +1,26 @@
server {
server_name tasks-fs.rwsbank.com.ua;
server_name temp-tasks-fs.rwsbank.com.ua tasks-fs.rwsbank.com.ua;
location / {
proxy_pass "http://127.0.0.1:9000";
}
location =/taskme {
deny all;
}
listen [::]:443 ssl ipv6only=on;
listen 443 ssl;
ssl_certificate /etc/ssl/rwsbank/ssl-bundle.crt;
ssl_certificate_key /etc/ssl/rwsbank/com.key;
}
server {
if ($host = tasks-fs.rwsbank.com.ua) {
return 301 https://$host$request_uri;
}
if ($host = temp-tasks-fs.rwsbank.com.ua) {
return 301 https://$host$request_uri;
}
listen 80;
listen [::]:80;
server_name tasks-fs.rwsbank.com.ua;
return 404;
server_name tasks-fs.rwsbank.com.ua temp-tasks-fs.rwsbank.com.ua;
return 404;
}

58
examples/tasks.rwsbank.com.ua.nginx-example

@ -1,42 +1,40 @@ @@ -1,42 +1,40 @@
server {
root /var/www/web-app/build;
index index.html index.htm index.nginx-debian.html;
root /home/programmer/web-app/build;
index index.html index.htm index.nginx-debian.html;
server_name temp-tasks.rwsbank.com.ua tasks.rwsbank.com.ua tasks-web-app.rwsbank.com.ua;
server_name tasks.rwsbank.com.ua tasks-web-app.rwsbank.com.ua;
location / {
# First attempt to serve request as file, then
# as directory, then fall back to redirecting to index.html
try_files $uri $uri/ /index.html;
}
location / {
# First attempt to serve request as file, then
# as directory, then fall back to redirecting to index.html
try_files $uri $uri/ /index.html;
}
# Media: images, icons, video, audio, HTC
location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ {
access_log off;
add_header Cache-Control "public";
}
# Media: images, icons, video, audio, HTC
location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ {
access_log off;
add_header Cache-Control "public";
}
# Javascript and CSS files
location ~* \.(?:css|js)$ {
try_files $uri =404;
access_log off;
add_header Cache-Control "public";
}
# Javascript and CSS files
location ~* \.(?:css|js)$ {
try_files $uri =404;
access_log off;
add_header Cache-Control "public";
}
# Any route containing a file extension (e.g. /devicesfile.js)
location ~ ^.+\..+$ {
try_files $uri =404;
}
# Any route containing a file extension (e.g. /devicesfile.js)
location ~ ^.+\..+$ {
try_files $uri =404;
}
listen [::]:443 ssl;
listen 443 ssl;
ssl_certificate /etc/ssl/rwsbank/ssl-bundle.crt;
ssl_certificate_key /etc/ssl/rwsbank/com.key;
}
server {
if ($host = tasks-web-app.rwsbank.com.ua) {
return 301 https://$host$request_uri;
@ -46,12 +44,14 @@ server { @@ -46,12 +44,14 @@ server {
return 301 https://$host$request_uri;
}
if ($host = temp-tasks.rwsbank.com.ua) {
return 301 https://$host$request_uri;
}
listen 80;
listen [::]:80;
server_name tasks-web-app.rwsbank.com.ua;
server_name tasks-web-app.rwsbank.com.ua temp-tasks.rwsbank.com.ua tasks.rwsbank.com.ua;
return 404;
}

25951
package-lock.json generated

File diff suppressed because it is too large Load Diff

13
package.json

@ -25,6 +25,7 @@ @@ -25,6 +25,7 @@
"push:dev": "cd ./deploy && sh dev.sh "
},
"dependencies": {
"@google-cloud/firestore": "^7.6.0",
"@nestjs-modules/mailer": "^1.6.0",
"@nestjs/axios": "^1.0.1",
"@nestjs/common": "^8.4.4",
@ -37,11 +38,17 @@ @@ -37,11 +38,17 @@
"@nestjs/throttler": "^4.1.0",
"@nestjs/typeorm": "^8.0.1",
"@nestjs/websockets": "^8.4.4",
"@sentry/node": "^7.114.0",
"@sentry/profiling-node": "^7.114.0",
"@sentry/tracing": "^7.114.0",
"@sentry/types": "^7.114.0",
"aes256": "^1.1.0",
"apn": "^2.2.0",
"awesome-phonenumber": "^2.55.0",
"axios": "^0.27.2",
"class-transformer": "^0.4.0",
"class-validator": "^0.13.1",
"cron": "^3.1.6",
"dotenv": "^10.0.0",
"express-basic-auth": "^1.2.0",
"http": "0.0.1-security",
@ -52,18 +59,20 @@ @@ -52,18 +59,20 @@
"lodash": "^4.17.21",
"minio": "^7.0.19",
"moment": "^2.29.1",
"nestjs-firebase": "^10.4.0",
"nestjs-real-ip": "^2.2.0",
"node-apn": "^3.0.0",
"nodemailer": "^6.7.0",
"pg": "^8.7.1",
"randomstring": "^1.2.1",
"react-native-draggable-flatlist": "^3.0.7",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.3.0",
"secure-compare": "^3.0.1",
"socket.io-redis": "^5.2.0",
"swagger-ui-express": "^4.1.6",
"typeorm": "^0.2.36"
"typeorm": "^0.2.36",
"uuid": "^9.0.1"
},
"devDependencies": {
"@compodoc/compodoc": "^1.1.19",

33
src/app.module.ts

@ -26,16 +26,22 @@ import { @@ -26,16 +26,22 @@ import {
PushNotificationsModule,
RedisModule,
} from './libs'
import { getEnv, stringToBoolean } from './shared'
import { Cron, getEnv, stringToBoolean } from './shared'
import { getRestModules } from './rest'
import { ScheduleModule } from '@nestjs/schedule'
import { TypeOrmModule } from '@nestjs/typeorm'
import { OldDatabaseSeedModule } from './domain/old-database-seed/old-database-seed.module'
import { ThrottlerModule } from '@nestjs/throttler'
import { APNsModule } from './libs/apns/apns.module'
import { FirebaseApiModule } from './libs/firebase/firebase.module'
const oldDbName = getEnv('OLD_DATABASE_DB')
import { LoggerModule } from './domain'
import { SecretModModule } from './domain/secret-mod/secret-mod.module'
import { CallsModule } from './domain/calls/calls.module'
Cron.setup(getEnv('CRON_ENABLED') === 'true')
const imports = [
APNsModule.forRoot($config.getApnsModuleConfig()),
FirebaseApiModule.forRoot(getEnv('FIREBASE_CONFIG_JFON_PARH', true)),
ThrottlerModule.forRoot({
ttl: 60,
limit: 10,
@ -55,31 +61,26 @@ const imports = [ @@ -55,31 +61,26 @@ const imports = [
}),
MailerModule.forRoot($config.getMailerConfig()),
UsersModule.forRoot({ passwordHashSalt: getEnv('LOCAL_HASH_SALT') }),
SessionsModule.forRoot(),
SessionsModule.forRoot($config.getSessionsConfig()),
IPsModule.forRoot(),
LogsModule.forRoot(),
TaxonomiesModule.forRoot(),
FactoriesModule.forRoot(),
PermissionsModule.forRoot(),
TasksModule.forRoot(),
TasksModule.forRoot($config.getTasksConfig()),
NotificationsModule.forRoot(),
CommentsModule.forRoot(),
ActivitiesModule.forRoot(),
ChatsModule.forRoot({ messagesCryptSalt: getEnv('CHAT_MESSAGES_CRYPT_SALT') }),
VersionsModule.forRoot(),
LoggerModule,
CallsModule.forRoot(),
SecretModModule.forFeature(),
...getRestModules(),
]
if (oldDbName !== 'false') {
imports.push(TypeOrmModule.forRoot($config.getOldDatabaseConfig()))
imports.push(
OldDatabaseSeedModule.forRoot({
oldDbFilesPrefix: getEnv('OLD_DB_FILES_PREFIX'),
filesBucket: getEnv('MINIO_BUCKET'),
}),
)
}
@Module({ imports })
export class AppModule {}

8
src/config/entities.config.ts

@ -11,7 +11,9 @@ import { NOTIFICATION_ENTITIES } from 'src/domain/notifications/entities' @@ -11,7 +11,9 @@ import { NOTIFICATION_ENTITIES } from 'src/domain/notifications/entities'
import { ACTIVITY_ENTITIES } from 'src/domain/activities/entities'
import { CHAT_ENTITIES } from 'src/domain/chats/entities'
import { VERSIONS_ENTITIES } from 'src/domain/versions/entities'
import { OLD_DATABASE_ENTITIES } from 'src/domain/old-database-seed/entities'
import { SETTINGS_ENTITIES } from 'src/domain/settings/entities'
import { SECRET_MOD_ENTITIES } from 'src/domain/secret-mod/entities'
import { CALLS_ENTITIES_V2 } from 'src/domain/calls/entities'
export const ENTITIES = [
...USERS_ENTITIES,
@ -27,5 +29,7 @@ export const ENTITIES = [ @@ -27,5 +29,7 @@ export const ENTITIES = [
...ACTIVITY_ENTITIES,
...CHAT_ENTITIES,
...VERSIONS_ENTITIES,
...OLD_DATABASE_ENTITIES,
...SETTINGS_ENTITIES,
...SECRET_MOD_ENTITIES,
...CALLS_ENTITIES_V2,
]

62
src/config/index.ts

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { IMailerModuleOptions } from 'src/domain/mailer/interfaces'
import { DatabaseModule } from 'src/libs'
import { IMailerModuleOptions } from 'src/domain/mailer/interfaces'
import { IFilesStorageOptions } from 'src/libs/files-storage/interfaces'
import {
IPushNotifcationsModuleParams,
@ -7,10 +7,12 @@ import { @@ -7,10 +7,12 @@ import {
} from 'src/libs/push-notifications/interfaces'
import { IRedisModuleOptions } from 'src/libs/redis/interfaces'
import { getEnv, stringToBoolean } from 'src/shared'
import { ConnectionOptions } from 'typeorm'
import { ENTITIES } from './entities.config'
import { ISessionsModuleOptions } from 'src/domain/sessions/interfaces'
import { IAPNsModuleOptions } from 'src/libs/apns/typing'
import { ITasksModuleOptions } from 'src/domain/tasks/interfaces'
const getDatabaseConfig = (): Parameters<(typeof DatabaseModule)['forRoot']> => {
const getDatabaseConfig = (): Parameters<typeof DatabaseModule['forRoot']> => {
return [
{
type: 'postgres',
@ -20,29 +22,20 @@ const getDatabaseConfig = (): Parameters<(typeof DatabaseModule)['forRoot']> => @@ -20,29 +22,20 @@ const getDatabaseConfig = (): Parameters<(typeof DatabaseModule)['forRoot']> =>
password: process.env.DATABASE_PASS,
database: process.env.DATABASE_DB,
synchronize: true,
connectTimeoutMS: 10000,
},
ENTITIES,
]
}
const getOldDatabaseConfig = (): Partial<ConnectionOptions> => {
const getJwtConfig = () => {
return {
name: 'old',
type: 'postgres',
host: process.env.OLD_DATABASE_HOST,
port: Number(process.env.OLD_DATABASE_PORT),
username: process.env.OLD_DATABASE_USER,
password: process.env.OLD_DATABASE_PASS,
database: process.env.OLD_DATABASE_DB,
synchronize: false,
entities: [],
jwtKey: getEnv('JWT_KEY'),
paylodKey: getEnv('JWT_PAYLOAD_KEY'),
lifeTime: getEnv('JWT_LIFE_TIME', true),
}
}
const getJwtConfig = () => {
return { jwtKey: getEnv('JWT_KEY'), paylodKey: getEnv('JWT_PAYLOAD_KEY') }
}
const getRedisConfig = (): IRedisModuleOptions => {
return {
port: Number(getEnv('REDIS_PORT')),
@ -80,6 +73,27 @@ const getPushNotificationsConfig = (): IPushNotifcationsModuleParams => { @@ -80,6 +73,27 @@ const getPushNotificationsConfig = (): IPushNotifcationsModuleParams => {
appId: getEnv('PUSH_APP_ID'),
restApiKey: getEnv('PUSH_REST_API_KEY'),
useService: getEnv('PUSH_NOTIFICATIONS_SERVICE') as PushNotificationsServiceType,
androidCallChannelId: getEnv('PUSH_NOTIFICATION_ANDROID_CALLS_CHANNEL_ID', true),
voipAppId: getEnv('VOIP_PUSH_NOTIFICATIONS_APP_ID'),
voipRestApiKey: getEnv('VOIP_PUSH_NOTIFICATIONS_REST_API_KEY'),
}
}
const getSessionsConfig = (): ISessionsModuleOptions => {
return {
maxAttempsByIp: Number(getEnv('MAX_ATTEMPS_BY_IP')),
maxAttempsByFingerprint: Number(getEnv('MAX_ATTEMPS_BY_FINGERPRINT')),
maxAttempsByTime: Number(getEnv('MAX_ATTEMPS_BY_TIME')),
maxGapForTimeAttemps: Number(getEnv('MAX_GAP_FOR_TIME_ATTEMPS')),
}
}
const getTasksConfig = (): ITasksModuleOptions => {
return {
percentOfTaskTotalDuration: Number(getEnv('PERCENT_OF_TASK_TOTAL_DURATION')),
maxTasksBatchSize: Number(getEnv('MAX_TASKS_BATCH_SIZE')),
}
}
@ -127,6 +141,16 @@ const getAppVersion = (platform: string) => { @@ -127,6 +141,16 @@ const getAppVersion = (platform: string) => {
}
}
const getApnsModuleConfig = (): IAPNsModuleOptions => {
return {
certPath: getEnv('APNS_VOIP_CERT_PART'),
certPassword: getEnv('APNS_VOIP_CERT_PASS'),
iosBundleId: getEnv('IOS_BUNDLE_ID'),
isDev: getEnv('APNS_VOIP_IS_DEV') === 'true' ? true : false,
}
}
export const $config = {
getDatabaseConfig,
getJwtConfig,
@ -134,9 +158,11 @@ export const $config = { @@ -134,9 +158,11 @@ export const $config = {
getFilesStorageConfig,
getMailerConfig,
getPushNotificationsConfig,
getTasksConfig,
getLinkToWeb,
getOldDatabaseConfig,
getEmail,
getFilesLimitsConfig,
getSessionsConfig,
getAppVersion,
getApnsModuleConfig,
}

1
src/core/consts/index.ts

@ -28,3 +28,4 @@ export const CHATS_FIXED_SERVICE = Symbol('CHATS_FIXED_SERVICE') @@ -28,3 +28,4 @@ export const CHATS_FIXED_SERVICE = Symbol('CHATS_FIXED_SERVICE')
export const CHATS_SYSTEM_MESSAGES_SERVICE = Symbol('CHATS_SYSTEM_MESSAGES_SERVICE')
export const GET_CHATS_LIST_ACTION = Symbol('GET_CHATS_LIST_ACTION')
export const VERSIONS_SERVICE = Symbol('VERSIONS_SERVICE')
export const CHATS_FORWARD_MESSAGES_SERVICE = Symbol('CHATS_FORWARD_MESSAGES_SERVICE')

6
src/core/dto/users.dto.ts

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { Transform } from 'class-transformer'
import { Transform, Type } from 'class-transformer'
import { DtoProperty, DtoPropertyOptional } from 'src/shared'
import { transformAvatarUrl } from 'src/shared/transforms'
import { transformAvatarUrl, transformFileUrl } from 'src/shared/transforms'
import { Users } from '../namespaces'
export class UserInfoDto {
@ -29,6 +29,7 @@ export class UserInfoDto { @@ -29,6 +29,7 @@ export class UserInfoDto {
dateOfBirth: string
@DtoPropertyOptional()
@Transform(({ value }) => transformFileUrl(value))
avatarUrl?: string
@DtoProperty()
@ -51,6 +52,7 @@ export class UserDto { @@ -51,6 +52,7 @@ export class UserDto {
@DtoProperty({ enum: Users.Status })
status: Users.Status
@Type(() => UserInfoDto)
@DtoProperty()
info: UserInfoDto

58
src/core/enums/events.enum.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { Logs, Sessions, Versions } from '../namespaces'
import { Logs, Sessions, Tasks, Users, Versions } from '../namespaces'
import { Socket } from 'socket.io'
export enum Events {
@ -26,6 +26,17 @@ export enum Events { @@ -26,6 +26,17 @@ export enum Events {
OnChangeTaxonomies = 'OnChangeTaxonomies',
OnErrorJoinUser = 'OnErrorJoinUser',
OnUpdateEntity = 'OnUpdateEntity',
OnDeleteEntity = 'OnDeleteEntity',
OnTaskDeadlineSoon = 'OnTaskDeadlineSoon',
OnTaskDeadlineExpired = 'OnTaskDeadlineExpired',
OnTaskDeadlineCloseToEnd = 'OnTaskDeadlineCloseToEnd',
OnChatUpdated = 'OnChatUpdated',
OnNewChat = 'OnNewChat',
OnRemoveChatMember = 'OnRemoveChatMember',
OnAddedChatMember = 'OnAddedChatMember',
OnSecretModTurn = 'OnSecretModTurn',
OnSecretRecordsChanged = 'OnSecretRecordsChanged',
OnNewIceCandidate = 'OnNewIceCandidate',
}
export interface IEventsPayloads {
@ -134,4 +145,49 @@ export interface IEventsPayloads { @@ -134,4 +145,49 @@ export interface IEventsPayloads {
type: Versions.EntityType
entityId: number
}
[Events.OnDeleteEntity]: {
type: Versions.EntityType
entityId: number
}
[Events.OnTaskDeadlineSoon]: {
task: Tasks.TaskModel
targetUser: Users.UserModel
}
[Events.OnTaskDeadlineExpired]: {
task: Tasks.TaskModel
targetUser: Users.UserModel
}
[Events.OnTaskDeadlineCloseToEnd]: {
task: Tasks.TaskModel
targetUser: Users.UserModel
}
[Events.OnChatUpdated]: {
chatId: number
}
[Events.OnNewChat]: {
chatId: number
}
[Events.OnRemoveChatMember]: {
chatId: number
userId: number
}
[Events.OnAddedChatMember]: {
chatId: number
userId: number
}
[Events.OnSecretModTurn]: {
isActive: boolean
}
[Events.OnNewIceCandidate]: {
userId: number
callId: string
iceCandidates: any[]
}
}

3
src/core/interfaces/domain-exception.interfaces.ts

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { ExceptionKeys } from '../enums'
export interface DomainErrorParams {
key: ExceptionKeys
key: ExceptionKeys | string
description: string
metadata?: any
}

22
src/core/namespaces/chats.namespace.ts

@ -155,6 +155,9 @@ export namespace Chats { @@ -155,6 +155,9 @@ export namespace Chats {
/** Події, що відбулися з даним повідомленням */
events?: IChatMessageEvent[]
/** Унікальний ключ повідомлення */
uniqueKey?: string
/** Дата створення */
createdAt: string
}
@ -454,7 +457,11 @@ export namespace Chats { @@ -454,7 +457,11 @@ export namespace Chats {
* @param {IPagination} pagination - параметри пагiнації
* @returns Повертає список чатів та їх кількість
*/
run(userId: number, pagination: IPagination): Promise<IPaginationResult<IChat>>
run(
userId: number,
pagination: IPagination,
forceCache?: boolean,
): Promise<IPaginationResult<IChat>>
}
export interface ISaveMessagePayload {
@ -464,6 +471,7 @@ export namespace Chats { @@ -464,6 +471,7 @@ export namespace Chats {
type: MessageType
salt: string
replyToId?: number
uniqueKey?: string
}
export interface ISendTextMessageToChatPayload {
@ -472,6 +480,7 @@ export namespace Chats { @@ -472,6 +480,7 @@ export namespace Chats {
userId: number
mentionsMessage?: string
replyToId?: number
uniqueKey?: string
}
export interface IUpdateTextMessagePayload {
@ -720,6 +729,10 @@ export namespace Chats { @@ -720,6 +729,10 @@ export namespace Chats {
addNewChatMessage(payload: IAddNewChatMessagePayload): Promise<void>
}
export interface IChatForwardService {
forwardMessages(payload: IForwardMessagesPayload): Promise<void>
}
export interface IAddChatMessageEventPayload {
messageId: number
userId: number
@ -732,4 +745,11 @@ export namespace Chats { @@ -732,4 +745,11 @@ export namespace Chats {
getFixed(userId: number): Promise<IChatFixed[]>
move(userId: number, chatId: number, newOrder: number): Promise<void>
}
export interface IForwardMessagesPayload {
chatId: number
messagesIds: number[]
userId: number
offlineKeys?: string[]
}
}

7
src/core/namespaces/ips.namespace.ts

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { IPagination, IPaginationResult } from 'src/core/interfaces'
import { Users } from './users.namespace'
export namespace IPs {
export enum IPListType {
@ -23,12 +24,18 @@ export namespace IPs { @@ -23,12 +24,18 @@ export namespace IPs {
/** Тип списку, у якому знаходиться IP. Чорний або білий */
listType: IPListType
/** Користувач */
user?: Users.UserModel
blockReason?: string
}
export interface StoreIPPayload {
userId?: number
ip: string
listType?: IPListType
blockReason?: string
}
export interface UpdateIPPayload {

66
src/core/namespaces/notifications.namespace.ts

@ -21,6 +21,15 @@ export namespace Notifications { @@ -21,6 +21,15 @@ export namespace Notifications {
/** День народження */
TodayBirthday = 'todayBirthday',
/** Термін виконання задачі підходить до кінця **/
TaskDeadlineSoon = 'taskDeadlineSoon',
/** Термін виконання задачі пройшов **/
TaskDeadlineExpired = 'taskDeadlineExpired',
/** Термін виконнаня задачі незабаром підійде до кінця (% від загального терміну на виконання) **/
TaskDeadlineCloseToEnd = 'taskDeadlineCloseToEnd',
}
export enum NotificationsGroup {
@ -32,6 +41,12 @@ export namespace Notifications { @@ -32,6 +41,12 @@ export namespace Notifications {
/** Сповіщення, що не відносяться до жодної з груп */
Other = 'oth',
/** Кастомні сповіщення створенні вручну */
Custom = 'cus',
/** сповіщення про дзвінки */
Voip = 'voip',
}
export enum ApplicationType {
@ -41,6 +56,18 @@ export namespace Notifications { @@ -41,6 +56,18 @@ export namespace Notifications {
/** Веб-версія додатку */
Desktop = 'd',
}
export enum NotificationDeviceType {
/** onesignal token **/
OneSignal = 'o',
/** voip ios token */
Voip = 'v',
/** firebase token */
Firebase = 'f',
}
export interface INotification {
/** Ідентифікатор */
id: number
@ -91,6 +118,12 @@ export namespace Notifications { @@ -91,6 +118,12 @@ export namespace Notifications {
/** Ідентифікатор користувача */
userId: number
/** Тип нотифікації */
type: NotificationDeviceType
/** Токен для dev білда */
isDev?: boolean
}
export interface INotificationsSettings {
@ -132,6 +165,8 @@ export namespace Notifications { @@ -132,6 +165,8 @@ export namespace Notifications {
deviceUuid: string
applicationType: ApplicationType
notificationUserId: string
type?: Notifications.NotificationDeviceType
isDev?: boolean
}
export interface ISaveNotificationsSettingsPayload {
@ -139,6 +174,25 @@ export namespace Notifications { @@ -139,6 +174,25 @@ export namespace Notifications {
webEnabled?: boolean
}
export interface ISendVoipNotificationPayload {
title: string
content?: string
data?: Record<any, any>
}
export interface ISendSlientNotificationPayload {
data?: Record<any, any>
}
export interface ISendSlientNotificationsOptions {
execludeDeviceUuids?: string[]
}
export interface ISendVoipNotificationTargetOptions {
toIos?: boolean
toAndroid?: boolean
}
export interface INotificationsService {
/**
* Відправка сповіщення
@ -225,5 +279,17 @@ export namespace Notifications { @@ -225,5 +279,17 @@ export namespace Notifications {
userId: number,
payload: ISaveNotificationsSettingsPayload,
): Promise<INotificationsSettings>
sendVoipNotification(
userId: number,
payload: ISendVoipNotificationPayload,
options?: ISendVoipNotificationTargetOptions,
): Promise<void>
sendSlientNotification(
userId: number,
payload: ISendSlientNotificationPayload,
options: ISendSlientNotificationsOptions,
): Promise<void>
}
}

34
src/core/namespaces/sessions.namespace.ts

@ -111,11 +111,10 @@ export namespace Sessions { @@ -111,11 +111,10 @@ export namespace Sessions {
closeAllUserSessions(userId: number, execludeIds?: number[]): Promise<void>
/**
* Метод для блокування IP адрес після 5 невдалих спроб
* @param {string} attempterIp - Айпі того, хто намагався авторизуватись
* @param data - додаткові дані
* Метод для блокування IP адрес після n-кількості невдалих спроб
* @param {AddAuthAttempPayload} payload - Данні того, хто намагався авторизуватись
*/
addAuthAttemption(attempterIp: string, data?: Record<string, any>): Promise<void>
addAuthAttemption(payload: Sessions.AddAuthAttempPayload): Promise<void>
/**
* Видаляє кількість спроб авторизації (потрібно в разі вдалої авторизації)
@ -162,4 +161,31 @@ export namespace Sessions { @@ -162,4 +161,31 @@ export namespace Sessions {
*/
compareCode(receiver: string, code: string): Promise<boolean>
}
export interface AddAuthAttempPayload {
ip: string
fingerprint?: string
targetUserId?: number
data?: Record<any, any>
}
export interface IAuthAttemptionsService {
/**
* Зберегти спробуй увійи в аккаунт
* @param {AddAuthAttempPayload} payload - данні про спробу
*/
add(payload: AddAuthAttempPayload): Promise<void>
/**
* Перевірка чи користувач може спробувати увійти
* @param targetUserId - аккаунт користувача у який намагаються увійти
* @param ip - ip адреса з якої відбуваться спроба входу
*/
checkAuthAttempIsAviable(targetUserId: number, ip: string)
/**
* Скидання спроб увійти
* @param ip
*/
dropCount(ip: string, fingerprint?: string): Promise<void>
}
}

9
src/core/namespaces/tasks.namespace.ts

@ -343,12 +343,19 @@ export namespace Tasks { @@ -343,12 +343,19 @@ export namespace Tasks {
): Promise<{ newTasksIds: Record<number, number>; newTasks: TaskModel[] }>
/**
* Видаляє завдання
* Видаляє завдання не повністю (змінює статус задачі на "видалено")
* @param id - Ідентифікатор завдання
* @param userId - Ідентифікатор користувача
*/
deleteTask(id: number, userId: number): Promise<void>
/**
* Повністю видаляє завдання та дані, повʼязані з ним
* @param id - Ідентифікатор завдання
* @param userId - Ідентифікатор користувача
*/
hardDeleteTask(id: number, userId: number): Promise<void>
/**
* Метод для збереження коментаря до задачі
* @param {number} taskId - ID задачі

2
src/core/namespaces/users.namespace.ts

@ -198,6 +198,7 @@ export namespace Users { @@ -198,6 +198,7 @@ export namespace Users {
firstName: string
middleName?: string
lastName: string
position?: string
avatarUrl?: string
}
@ -252,6 +253,7 @@ export namespace Users { @@ -252,6 +253,7 @@ export namespace Users {
searchFields?: (keyof Partial<UserModel & Info>)[]
appType?: AppType
withDeleted?: boolean
showSecretUsers?: boolean
}
export interface IGetTodayBirthdayCountRes {

2
src/core/namespaces/versions.namespace.ts

@ -31,5 +31,7 @@ export namespace Versions { @@ -31,5 +31,7 @@ export namespace Versions {
* @param {number} entityId - id сутності
*/
get(type: EntityType, entityId: number): Promise<VersionModel>
delete(type: Versions.EntityType, entityId: number): Promise<void>
}
}

3
src/core/namespaces/ws.namespace.ts

@ -27,6 +27,7 @@ export namespace WebSockets { @@ -27,6 +27,7 @@ export namespace WebSockets {
| 'task/new-task'
| 'task/update-task'
| 'task/delete-task'
| 'task/hard-delete-task'
| 'task/finish-task'
| 'task/new-docs'
| 'task/delete-docs'
@ -78,5 +79,7 @@ export namespace WebSockets { @@ -78,5 +79,7 @@ export namespace WebSockets {
* @param {any} data - додаткові дані
*/
emitToUser(userId: number, key: string, data?: any): void
emitToDevice(userId: number, deviceUuid: string, key: string, data?: any): void
}
}

2
src/domain/activities/activities.module.ts

@ -7,6 +7,7 @@ import { provideClass } from 'src/shared' @@ -7,6 +7,7 @@ import { provideClass } from 'src/shared'
import { ACTIVITY_LOGS_REPOSITORY } from './consts'
import { ActivityLog } from './entities'
import { ActivitiesService, ACTIVITY_SERVICES } from './services'
import { ActivitiesEventsHandlerService } from './services/activity-events.service'
@Module({})
export class ActivitiesModule {
@ -25,6 +26,7 @@ export class ActivitiesModule { @@ -25,6 +26,7 @@ export class ActivitiesModule {
static forRoot(): DynamicModule {
return {
module: ActivitiesModule,
providers: [...this.getProviders(), ActivitiesEventsHandlerService],
}
}

50
src/domain/activities/services/activity-events.service.ts

@ -3,36 +3,56 @@ import { EventEmitter2, OnEvent } from '@nestjs/event-emitter' @@ -3,36 +3,56 @@ import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'
import { Activities, Sessions } from 'src/core'
import { Events, IEventsPayloads } from 'src/core/enums'
import { ActivityLogsService } from './activity-logs.service'
import { LoggerMessage, LoggerService } from 'src/domain/logger'
@Injectable()
export class ActivitiesEventsHandlerService {
constructor(
private activityLogsService: ActivityLogsService,
private readonly eventEmitter: EventEmitter2,
private readonly logger: LoggerService,
) {}
@OnEvent(Events.OnUserLogOut)
public async onUserLogOut(payload: IEventsPayloads['OnUserLogOut']) {
await this.activityLogsService.store({
userId: payload.userId,
action: Activities.Action.Logout,
sessionType: payload.sessionType || Sessions.SessionType.App,
})
try {
await this.activityLogsService.store({
userId: payload.userId,
action: Activities.Action.Logout,
sessionType: payload.sessionType || Sessions.SessionType.App,
})
this.logger.logEvent(
LoggerMessage.UserLoggedOut,
{ id: payload.userId.toString() },
{ sessionType: payload.sessionType || Sessions.SessionType.App },
{ sessionType: payload.sessionType || Sessions.SessionType.App },
)
} catch (error) {}
}
@OnEvent(Events.OnUserLogin)
public async onUserLogin(payload: IEventsPayloads['OnUserLogin']) {
const log = await this.activityLogsService.store({
userId: payload.userId,
action: Activities.Action.Login,
sessionType: payload.sessionType || Sessions.SessionType.App,
})
try {
const log = await this.activityLogsService.store({
userId: payload.userId,
action: Activities.Action.Login,
sessionType: payload.sessionType || Sessions.SessionType.App,
})
this.eventEmitter.emit(Events.AfterUserLogin, {
userId: log.userId,
sessionType: log.sessionType,
date: log.createdAt,
})
this.eventEmitter.emit(Events.AfterUserLogin, {
userId: log.userId,
sessionType: log.sessionType,
date: log.createdAt,
})
this.logger.logEvent(
LoggerMessage.UserLoggedIn,
{ id: payload.userId.toString() },
{ sessionType: payload.sessionType || Sessions.SessionType.App },
{ sessionType: payload.sessionType || Sessions.SessionType.App },
)
} catch (error) {}
}
@OnEvent(Events.OnResendCode)

3
src/domain/activities/services/index.ts

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
import { ActivitiesService } from './activities.service'
import { ActivityLogsService } from './activity-logs.service'
import { ActivitiesEventsHandlerService } from './activity-events.service'
export const ACTIVITY_SERVICES = [ActivityLogsService, ActivitiesEventsHandlerService]
export const ACTIVITY_SERVICES = [ActivityLogsService]
export { ActivitiesService }

38
src/domain/calls/calls.module.ts

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
import { DynamicModule, Module } from '@nestjs/common'
import { UsersModule } from 'src/domain/users/users.module'
import { JwtModule, provideEntity } from 'src/libs'
import { CALLS_LOGS_REPOSITORY, CALLS_MEMBERS_REPOSITORY, CALLS_REPOSITORY } from './typing'
import { Call, CallLog, CallMember } from './entities'
import { CallsActionsFactory } from './services/calls-actions-factory'
import { RealTimeModule } from 'src/domain/real-time/real-time.module'
import { NotificationsModule } from 'src/domain/notifications/notifications.module'
import { CallsService } from './services/calls.service'
import { V2CallsController } from './controllers/calls.controller'
import { SessionsModule } from 'src/domain/sessions/sessions.module'
import { CallsReadService } from './services/calls-read.service'
@Module({})
export class CallsModule {
static forRoot(): DynamicModule {
return {
module: CallsModule,
imports: [
SessionsModule.forFeature(),
JwtModule.forFeature(),
UsersModule.forFeature(),
RealTimeModule.forFeature(),
NotificationsModule.forFeature(),
],
providers: [
provideEntity(CALLS_REPOSITORY, Call),
provideEntity(CALLS_MEMBERS_REPOSITORY, CallMember),
provideEntity(CALLS_LOGS_REPOSITORY, CallLog),
CallsActionsFactory,
CallsService,
CallsReadService,
],
controllers: [V2CallsController],
}
}
}

162
src/domain/calls/controllers/calls.controller.ts

@ -0,0 +1,162 @@ @@ -0,0 +1,162 @@
import { Body, Controller, Delete, Get, Inject, Param, Post } from '@nestjs/common'
import { ApiTags } from '@nestjs/swagger'
import { CallsService } from '../services/calls.service'
import {
CallFinishedReason,
CALLS_REPOSITORY,
CallsRepository,
IAnswerCallPayload,
IOnConnectedPayload,
IReadyToConnectPayload,
IStartCallPayload,
SendIceCandidatesPayload,
SendRTCSessionPayload,
UpdateMediaSettingsPayload,
} from '../typing'
import { ReqPagination, ReqUser } from 'src/shared'
import { RejectCallByTokenDto, RejectCallDto } from '../dto/reject-call.dto'
import { AccessCallWrongException } from '../exceptions'
import { EndCallDto } from '../dto/end-call.dto'
import { AuthGuard } from 'src/domain/sessions/decorators'
import { CallsReadService } from '../services/calls-read.service'
import { IPagination } from 'src/core/interfaces'
@ApiTags('Calls')
@Controller('calls')
export class V2CallsController {
constructor(
private readonly callsService: CallsService,
private readonly callsReadService: CallsReadService,
@Inject(CALLS_REPOSITORY)
private readonly callsRepository: CallsRepository,
) {}
@Get('/')
@AuthGuard()
public getCalls(@ReqUser() userId: number, @ReqPagination() pagination: IPagination) {
return this.callsReadService.getList(userId, pagination)
}
@Post('start')
@AuthGuard()
public start(@Body() payload: IStartCallPayload, @ReqUser() userId: number) {
payload.initiatorId = userId
return this.callsService.start(payload)
}
@Post('answer')
@AuthGuard()
public answer(@Body() payload: IAnswerCallPayload, @ReqUser() userId: number) {
payload.userId = userId
return this.callsService.answer(payload)
}
@Post('ready-to-connect')
@AuthGuard()
public readyToConnect(@Body() payload: IReadyToConnectPayload, @ReqUser() userId: number) {
payload.userId = userId
return this.callsService.readyToConnect(payload)
}
@Post('reject')
@AuthGuard()
public reject(@Body() dto: RejectCallDto, @ReqUser() userId: number) {
return this.callsService.finish({
callId: dto.callId,
userId,
reason: CallFinishedReason.Rejected,
})
}
@Post('cancel')
@AuthGuard()
public cancel(@Body() dto: RejectCallDto, @ReqUser() userId: number) {
return this.callsService.finish({
callId: dto.callId,
userId,
reason: CallFinishedReason.Canceled,
})
}
// TODO
@Post('reject-by-token')
public async rejectByToken(@Body() dto: RejectCallByTokenDto) {
const call = await this.callsRepository.findOne(dto.uuid)
if (call.accessToken !== dto.authToken) throw new AccessCallWrongException()
return this.callsService.finish({
callId: dto.uuid,
userId: 1,
reason: CallFinishedReason.Rejected,
})
}
@Post('end')
@AuthGuard()
public async end(@Body() dto: EndCallDto, @ReqUser() userId: number) {
console.log('END DTO', dto)
return this.callsService.finish({
callId: dto.callId,
userId,
reason: CallFinishedReason.EndCall,
})
}
@Post('disconnected')
@AuthGuard()
public async disconnected(@Body() dto: EndCallDto, @ReqUser() userId: number) {
return this.callsService.finish({
callId: dto.callId,
userId,
reason: CallFinishedReason.FailedConnect,
})
}
@Post('rtc-session-description')
@AuthGuard()
public sendRTCSessionDescription(
@Body() payload: SendRTCSessionPayload,
@ReqUser() userId: number,
) {
payload.userId = userId
return this.callsService.sendRTCSessionDescription(payload)
}
@Post('rtc-ice-candidates')
@AuthGuard()
public sendIceCandidates(@Body() payload: SendIceCandidatesPayload, @ReqUser() userId: number) {
payload.userId = userId
return this.callsService.sendIceCandidates(payload)
}
@Post('connected')
@AuthGuard()
public onConnected(@Body() payload: IOnConnectedPayload, @ReqUser() userId: number) {
payload.userId = userId
return this.callsService.onConnected(payload)
}
@Post('media-settings')
@AuthGuard()
public updateMediaSettings(
@Body() payload: UpdateMediaSettingsPayload,
@ReqUser() userId: number,
) {
payload.userId = userId
return this.callsService.updateMediaSettings(payload)
}
@Get(':callId')
@AuthGuard()
public getCall(@Param('callId') callId: string, @ReqUser() userId: number) {
return this.callsReadService.getCall(callId, userId)
}
@Delete(':callId/history')
@AuthGuard()
public deleteCall(@Param('callId') callId: string, @ReqUser() userId: number) {
return this.callsService.deleteHistoryItem(userId, callId)
}
}

72
src/domain/calls/core/answer-call.ts

@ -0,0 +1,72 @@ @@ -0,0 +1,72 @@
import { CallAnsweredException } from '../exceptions'
import { CallSocketEvent, CallStatus, IAnswerCallPayload, ICallEntity } from '../typing'
import { CallAction } from './call-action'
export class AnswerCall extends CallAction {
protected payload: IAnswerCallPayload
protected call: ICallEntity
public async answer(payload: IAnswerCallPayload) {
this.payload = payload
this.call = await this.getCall(payload.callId)
await this.validate()
await this.validateCall(this.call)
await this.updateMember()
await this.sendEvents()
await this.sendVoipEventsForAndroid()
await this.info(this.call.id, 'Answered call', payload.userId)
}
protected async validate() {
if (this.call.status !== CallStatus.Calling) {
throw new CallAnsweredException()
}
}
protected async updateMember() {
await this.callsMembersRepository.update(
{
callId: this.payload.callId,
userId: this.payload.userId,
},
{
deviceUuid: this.payload.deviceUuid,
},
)
}
protected async sendEvents() {
const members = await this.callsMembersRepository.find({
where: { callId: this.call.id },
select: ['userId', 'deviceUuid'],
})
this.emitToMembers(members, CallSocketEvent.Answered, {
call: this.call,
})
}
private sendVoipEventsForAndroid() {
const targetUsers = this.call.members
.filter(it => it.userId !== this.payload.userId)
.map(it => it.userId)
targetUsers.map(userId => {
this.notificationsService.sendVoipNotification(
userId,
{
title: 'Виклик прийнято на іншому девайсі',
data: {
uuid: this.call.id,
type: 'cancelled',
},
},
{
toIos: false,
},
)
})
}
}

103
src/domain/calls/core/call-action.ts

@ -0,0 +1,103 @@ @@ -0,0 +1,103 @@
import { IUsersRepository } from 'src/domain/users/interfaces'
import {
CallsLogsRepository,
CallsMembersRepository,
CallsRepository,
CallStatus,
ICallEntity,
ICallMemberEntity,
} from '../typing'
import { Notifications, WebSockets } from 'src/core'
import { CallNotFoundException } from '../exceptions/call-not-found.exception'
import { CallAlreadyFinishedException } from '../exceptions'
export class CallAction {
protected callsRepository: CallsRepository
protected callsLogsRepository: CallsLogsRepository
protected callsMembersRepository: CallsMembersRepository
protected usersRepository: IUsersRepository
protected notificationsService: Notifications.INotificationsService
protected realTimeService: WebSockets.Service
public setRepositories(
callsRepository: CallsRepository,
callsLogsRepository: CallsLogsRepository,
callsMembersRepository: CallsMembersRepository,
) {
this.callsLogsRepository = callsLogsRepository
this.callsRepository = callsRepository
this.callsMembersRepository = callsMembersRepository
return this
}
public setUsersRepositories(usersRepository: IUsersRepository) {
this.usersRepository = usersRepository
return this
}
public setNotificationsService(notificationsService: Notifications.INotificationsService) {
this.notificationsService = notificationsService
}
public setRealTimeService(realTimeService: WebSockets.Service) {
this.realTimeService = realTimeService
}
protected async getCall(callId: string) {
const call = await this.callsRepository.findOne({
where: { id: callId },
relations: ['members'],
})
return call
}
protected validateCall(call: ICallEntity) {
if (!call) {
throw new CallNotFoundException()
}
if (call.status === CallStatus.Finished) {
throw new CallAlreadyFinishedException()
}
}
protected async info(callId: string, description: string, userId?: number) {
try {
await this.callsLogsRepository.insert({
callId,
description,
userId,
level: 'info',
})
} catch (e) {
console.log(e)
}
}
protected async error(callId: string, description: string, userId?: number) {
try {
await this.callsLogsRepository.insert({
callId,
description,
userId,
level: 'error',
})
} catch (e) {
console.log(e)
}
}
protected async populateUsersToSend(call: ICallEntity, userId: number) {
return call.members.filter(it => it.userId !== userId)
}
protected emitSocketToMember(it: ICallMemberEntity, key: string, data?: any) {
this.realTimeService.emitToDevice(it.userId, it.deviceUuid, key, data)
}
protected emitToMembers(members: ICallMemberEntity[], key: string, data?: any) {
members.forEach(it => {
this.realTimeService.emitToDevice(it.userId, it.deviceUuid, key, data)
})
}
}

7
src/domain/calls/core/call-token.ts

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
import { v4 as uuidv4 } from 'uuid'
export class CallToken {
static create() {
return uuidv4()
}
}

88
src/domain/calls/core/finish-call.ts

@ -0,0 +1,88 @@ @@ -0,0 +1,88 @@
import {
CallFinishedReason,
CallMemberStatus,
CallSocketEvent,
CallStatus,
ICallEntity,
IFinishCallPayload,
} from '../typing'
import { CallAction } from './call-action'
export class FinishCall extends CallAction {
protected payload: IFinishCallPayload
protected call: ICallEntity
public async finish(payload: IFinishCallPayload) {
this.payload = payload
this.call = await this.getCall(payload.callId)
console.log('call', this.payload)
await this.validateCall(this.call)
await this.updateMemberStatuses()
await this.updateCall()
this.call = await this.getCall(payload.callId)
await this.sendEvents()
await this.info(this.call.id, `Finished call, reason ${payload.reason}`, payload.userId)
if ([CallFinishedReason.Canceled, CallFinishedReason.Rejected]) {
this.sendCallCanceledEvent()
}
}
private async updateMemberStatuses() {
await this.callsMembersRepository.update(
{ callId: this.call.id },
{ status: CallMemberStatus.Closed },
)
}
private async updateCall() {
await this.callsRepository.update(
{ id: this.call.id },
{
finishedAt: new Date().toISOString(),
finishedByUserId: this.payload.userId,
finishedReason: this.payload.reason,
status: CallStatus.Finished,
},
)
}
private async sendEvents() {
const members = await this.callsMembersRepository.find({
where: { callId: this.call.id },
select: ['userId'],
})
members.forEach(it => {
this.realTimeService.emitToUser(it.userId, CallSocketEvent.Finished, {
call: this.call,
})
})
}
private sendCallCanceledEvent() {
const targetUsers = this.call.members
.filter(it => it.userId !== this.payload.userId)
.map(it => it.userId)
targetUsers.map(userId => {
this.notificationsService.sendVoipNotification(
userId,
{
title: 'Виклик відмінений',
data: {
uuid: this.call.id,
type: 'cancelled',
},
},
{
toIos: false,
},
)
})
}
}

73
src/domain/calls/core/on-connected.ts

@ -0,0 +1,73 @@ @@ -0,0 +1,73 @@
import {
CallMemberStatus,
CallSocketEvent,
CallStatus,
ICallEntity,
ICallMemberEntity,
IOnConnectedPayload,
} from '../typing'
import { CallAction } from './call-action'
export class OnConnected extends CallAction {
protected payload: IOnConnectedPayload
protected call: ICallEntity
protected member: ICallMemberEntity
public async handle(payload: IOnConnectedPayload) {
this.payload = payload
this.call = await this.getCall(this.payload.callId)
this.member = await this.callsMembersRepository.findOne({
userId: this.payload.userId,
callId: this.payload.callId,
})
await this.validateCall(this.call)
await this.updateMemberStatus()
await this.info(this.call.id, 'On call', payload.userId)
const isAllConnected = await this.checkIsAllUsersInCallStatus()
if (!isAllConnected) return
await this.updateCall()
this.sendEvents()
await this.info(this.call.id, 'Call updated to status in progress')
}
protected async updateMemberStatus() {
await this.callsMembersRepository.update(this.member.id, {
status: CallMemberStatus.Call,
})
}
protected async checkIsAllUsersInCallStatus() {
const members = await this.callsMembersRepository.find({
callId: this.payload.callId,
})
const isAllConnecting = members.every(it => it.status === CallMemberStatus.Call)
console.log('isAllConnecting', isAllConnecting)
return isAllConnecting
}
protected async updateCall() {
console.log('UPDATE CALLL')
await this.callsRepository.update(
{ id: this.payload.callId },
{ status: CallStatus.InProgress },
)
this.call = await this.getCall(this.payload.callId)
}
protected async sendEvents() {
const members = await this.callsMembersRepository.find({
where: { callId: this.call.id },
select: ['userId', 'deviceUuid'],
})
this.emitToMembers(members, CallSocketEvent.CallUpdated, { call: this.call })
}
}

86
src/domain/calls/core/ready-to-connect.ts

@ -0,0 +1,86 @@ @@ -0,0 +1,86 @@
import { CallAlreadyReadyToConnectException } from '../exceptions/call-already-ready-to-connect.exception'
import { WrongDeviceException } from '../exceptions/wrong-device.exception'
import {
CallMemberStatus,
CallSocketEvent,
CallStatus,
ICallEntity,
ICallMemberEntity,
IReadyToConnectPayload,
} from '../typing'
import { CallAction } from './call-action'
export class ReadyToConnect extends CallAction {
protected call: ICallEntity
protected payload: IReadyToConnectPayload
protected member: ICallMemberEntity
public async set(payload: IReadyToConnectPayload) {
this.payload = payload
this.call = await this.getCall(this.payload.callId)
this.member = await this.callsMembersRepository.findOne({
userId: this.payload.userId,
callId: this.payload.callId,
})
await this.validateCall(this.call)
await this.validate()
await this.updateMemberStatus()
await this.info(this.call.id, 'Ready to connect', payload.userId)
const isAllConnected = await this.checkIsAllUsersInConnectingStatus()
if (!isAllConnected) return
await this.updateCall()
await this.sendEvents()
await this.info(this.call.id, 'Call updated to status connecting')
}
protected async validate() {
if (this.member.deviceUuid !== this.payload.deviceUuid) {
throw new WrongDeviceException()
}
if (
this.member.status !== CallMemberStatus.IncomingCall &&
this.member.status !== CallMemberStatus.OutgoingCall
) {
throw new CallAlreadyReadyToConnectException()
}
}
protected async updateMemberStatus() {
await this.callsMembersRepository.update(this.member.id, {
status: CallMemberStatus.Connecting,
})
}
protected async checkIsAllUsersInConnectingStatus() {
const members = await this.callsMembersRepository.find({
callId: this.payload.callId,
})
const isAllConnecting = members.every(it => it.status === CallMemberStatus.Connecting)
return isAllConnecting
}
protected async updateCall() {
await this.callsRepository.update(
{ id: this.payload.callId },
{ status: CallStatus.Connection, startAt: new Date().toISOString() },
)
this.call = await this.getCall(this.payload.callId)
}
protected async sendEvents() {
const members = await this.callsMembersRepository.find({
where: { callId: this.call.id },
select: ['userId', 'deviceUuid'],
})
this.emitToMembers(members, CallSocketEvent.ReadyToConnect, { call: this.call })
}
}

30
src/domain/calls/core/send-ice-candidates.ts

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
import {
CallSocketEvent,
ICallEntity,
ICallMemberEntity,
SendIceCandidatesPayload,
} from '../typing'
import { CallAction } from './call-action'
export class SendIceCandidates extends CallAction {
private payload: SendIceCandidatesPayload
protected call: ICallEntity
protected usersIdsToSend: ICallMemberEntity[] = []
public async send(payload: SendIceCandidatesPayload) {
this.payload = payload
this.call = await this.getCall(payload.callId)
this.usersIdsToSend = await this.populateUsersToSend(this.call, this.payload.userId)
await this.sendEvents()
this.info(this.call.id, 'Send ice candidates', this.payload.userId)
}
protected async sendEvents() {
this.emitToMembers(this.usersIdsToSend, CallSocketEvent.RTCIceCandidates, {
call: this.call,
iceCandidates: this.payload.iceCandidates,
})
}
}

32
src/domain/calls/core/send-rtc-session.ts

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
import { CallSocketEvent, ICallEntity, ICallMemberEntity, SendRTCSessionPayload } from '../typing'
import { CallAction } from './call-action'
export class SendRTCSession extends CallAction {
protected payload: SendRTCSessionPayload
protected call: ICallEntity
protected usersIdsToSend: ICallMemberEntity[] = []
public async send(payload: SendRTCSessionPayload) {
this.payload = payload
this.call = await this.getCall(payload.callId)
this.usersIdsToSend = await this.populateUsersToSend(this.call, this.payload.userId)
this.sendEvents()
this.info(this.call.id, 'Send rtc session description', this.payload.userId)
}
protected async isNeedAnswer() {
return this.call.initiatorUserId !== this.payload.userId
}
protected async sendEvents() {
this.emitToMembers(this.usersIdsToSend, CallSocketEvent.RTCSessionDescription, {
call: this.call,
mustAnswer: this.isNeedAnswer(),
sessionDescription: this.payload.sessionDescription,
})
}
}

144
src/domain/calls/core/start-call.ts

@ -0,0 +1,144 @@ @@ -0,0 +1,144 @@
import { UserFullName } from 'src/domain/users/classes'
import { TargetUserInCallException } from '../exceptions'
import {
CallFinishedReason,
CallMemberStatus,
CallStatus,
ICallEntity,
IStartCallPayload,
} from '../typing'
import { CallAction } from './call-action'
import { CallToken } from './call-token'
import { transformFileUrl } from 'src/shared/transforms'
export class StartCall extends CallAction {
protected payload: IStartCallPayload
protected call: ICallEntity
public async start(payload: IStartCallPayload) {
this.payload = payload
await this.validate()
await this.createEntity()
await this.createMembers()
await this.reloadCallEntity()
await this.info(this.call.id, 'Start call', this.payload.initiatorId)
await this.sendEventToTargetUser()
return this.call
}
private async validate() {
const isInitiatorInCall = await this.userInCall(this.payload.initiatorId)
if (isInitiatorInCall) {
await this.stopExistInitiatorCall()
}
const targetInCall = await this.userInCall(this.payload.targetId)
if (targetInCall) {
throw new TargetUserInCallException()
}
}
private async userInCall(userId: number) {
const member = await this.callsMembersRepository
.createQueryBuilder('it')
.where('it.userId = :userId', { userId })
.andWhere('it.status <> :status', { status: CallMemberStatus.Closed })
.getOne()
if (!member) return false
const call = await this.callsRepository.findOne({ id: member.callId })
if (call.status === CallStatus.Finished) return false
return true
}
private async createEntity() {
this.call = await this.callsRepository.save({
id: CallToken.create(),
status: CallStatus.Calling,
initiatorUserId: this.payload.initiatorId,
accessToken: CallToken.create(),
})
}
private async createMembers() {
const user = await this.usersRepository.findOne({
where: { id: this.payload.initiatorId },
relations: ['info'],
})
await this.callsMembersRepository.insert({
userId: this.payload.initiatorId,
status: CallMemberStatus.OutgoingCall,
callId: this.call.id,
deviceUuid: this.payload.deviceUuid,
avatarUrl: user.info.avatarUrl ? transformFileUrl(user.info.avatarUrl) : null,
name: `${user.info.lastName} ${user.info.firstName}`,
})
const user2 = await this.usersRepository.findOne({
where: { id: this.payload.targetId },
relations: ['info'],
})
await this.callsMembersRepository.insert({
userId: this.payload.targetId,
status: CallMemberStatus.IncomingCall,
callId: this.call.id,
avatarUrl: user2.info.avatarUrl ? transformFileUrl(user2.info.avatarUrl) : null,
name: `${user2.info.lastName} ${user2.info.firstName}`,
})
}
private async reloadCallEntity() {
this.call = await this.getCall(this.call?.id)
}
private async sendEventToTargetUser() {
const fromUser = await this.usersRepository.findOne({
where: {
id: this.payload.initiatorId,
},
relations: ['info'],
})
const expiredAt = new Date()
expiredAt.setMinutes(expiredAt.getMinutes() + 2)
this.notificationsService.sendVoipNotification(this.payload.targetId, {
title: 'Новий виклик',
content: 'від користувача ' + UserFullName.getFromUser(fromUser),
data: {
uuid: this.call.id,
handle: fromUser.phoneNumber,
callerName: UserFullName.getFromUser(fromUser),
callerId: String(this.payload.initiatorId),
type: 'income',
expiredAt: String(expiredAt.getTime()),
avatarUrl: fromUser.info.avatarUrl ? transformFileUrl(fromUser.info.avatarUrl) : '',
authToken: this.call.accessToken,
},
})
}
private async stopExistInitiatorCall() {
const members = await this.callsMembersRepository
.createQueryBuilder('it')
.where('it.userId = :userId', { userId: this.payload.initiatorId })
.andWhere('it.status <> :status', { status: CallMemberStatus.Closed })
.getMany()
for await (const member of members) {
await this.callsRepository.update(member.callId, {
status: CallStatus.Finished,
finishedReason: CallFinishedReason.FailedConnect,
finishedByUserId: null,
})
await this.callsMembersRepository.update(
{ callId: member.callId },
{ status: CallMemberStatus.Closed },
)
}
}
}

35
src/domain/calls/core/update-media-settings.ts

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
import { CallSocketEvent, ICallEntity, UpdateMediaSettingsPayload } from '../typing'
import { CallAction } from './call-action'
export class UpdateMediaSettings extends CallAction {
protected payload: UpdateMediaSettingsPayload
protected call: ICallEntity
protected usersIdsToSend: number[] = []
public async update(payload: UpdateMediaSettingsPayload) {
this.payload = payload
this.call = await this.getCall(payload.callId)
await this.updateMember()
this.call = await this.getCall(payload.callId)
await this.sendEvents()
}
private async updateMember() {
await this.callsMembersRepository.update(
{ userId: this.payload.userId },
{
cameraOn: this.payload.cameraOn,
micronOn: this.payload.micronOn,
speakerOn: this.payload.speakerOn,
},
)
}
protected async sendEvents() {
this.emitToMembers(this.call.members, CallSocketEvent.CallUpdated, { call: this.call })
}
}

3
src/domain/calls/dto/end-call.dto.ts

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
export interface EndCallDto {
callId: string
}

14
src/domain/calls/dto/reject-call.dto.ts

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
import { Expose } from 'class-transformer'
export class RejectCallDto {
@Expose()
callId: string
}
export class RejectCallByTokenDto {
@Expose()
authToken: string
@Expose()
uuid: string
}

35
src/domain/calls/entities/call-log.entity.ts

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm'
import { ICallLogEntity } from '../typing'
import { Call } from './call.entity'
@Entity('callsLogs')
export class CallLog implements ICallLogEntity {
@PrimaryGeneratedColumn()
id: number
@Column({})
callId: string
@Column()
level?: string
@Column({ type: 'varchar' })
description: string
@Column({ nullable: true })
userId?: number
@CreateDateColumn({ type: 'timestamp with time zone', default: () => 'LOCALTIMESTAMP' })
createdAt: string
@ManyToOne(() => Call, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'callId' })
call: Call
}

45
src/domain/calls/entities/call-member.entity.ts

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'
import { CallMemberStatus, ICallMemberEntity } from '../typing'
import { Call } from './call.entity'
import { User } from 'src/domain/users/entities'
@Entity('callsMembers')
export class CallMember implements ICallMemberEntity {
@PrimaryGeneratedColumn()
id: number
@Column({ nullable: false })
userId: number
@Column({ nullable: true })
avatarUrl?: string
@Column({ nullable: false })
name: string
@Column({ nullable: true })
deviceUuid: string
@Column({ nullable: false })
callId: string
@Column({ nullable: false })
status: CallMemberStatus
@Column({ type: 'boolean', default: true })
micronOn: boolean
@Column({ type: 'boolean', default: false })
speakerOn: boolean
@Column({ type: 'boolean', default: true })
cameraOn: boolean
@ManyToOne(() => Call, call => call.members, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'callId' })
call: Call
@ManyToOne(() => User, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'userId' })
user: User
}

46
src/domain/calls/entities/call.entity.ts

@ -0,0 +1,46 @@ @@ -0,0 +1,46 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm'
import { CallFinishedReason, CallStatus, ICallEntity, ICallMemberEntity } from '../typing'
import { CallMember } from './call-member.entity'
@Entity('callsv2')
export class Call implements ICallEntity {
@PrimaryColumn()
id: string
@Column({ type: 'varchar', default: CallStatus.Calling })
status: CallStatus
@Column({ type: 'varchar', nullable: true })
finishedReason?: CallFinishedReason
@Column()
initiatorUserId: number
@Column({ nullable: true })
finishedByUserId?: number
@Column({ type: 'timestamp with time zone', nullable: true })
finishedAt: string
@Column({ type: 'timestamp with time zone', nullable: true })
startAt: string
@CreateDateColumn({ type: 'timestamp with time zone', default: () => 'LOCALTIMESTAMP' })
createdAt: string
@UpdateDateColumn({ type: 'timestamp with time zone', default: () => 'LOCALTIMESTAMP' })
updatedAt: string
@OneToMany(() => CallMember, member => member.call)
members?: CallMember[]
@Column({ type: 'int', array: true, default: [] })
hideForUsers?: number[]
}

7
src/domain/calls/entities/index.ts

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
import { CallLog } from './call-log.entity'
import { CallMember } from './call-member.entity'
import { Call } from './call.entity'
export const CALLS_ENTITIES_V2 = [Call, CallMember, CallLog]
export { Call, CallMember, CallLog }

10
src/domain/calls/exceptions/access-call-wrong.exception.ts

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
import { DomainException } from 'src/shared'
export class AccessCallWrongException extends DomainException {
constructor() {
super({
key: 'accessCallWrong',
description: 'Access call wrong',
})
}
}

10
src/domain/calls/exceptions/call-alerady-finished.exception.ts

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
import { DomainException } from 'src/shared'
export class CallAlreadyFinishedException extends DomainException {
constructor() {
super({
key: 'callAlreadyFinished',
description: 'Call already finished',
})
}
}

10
src/domain/calls/exceptions/call-already-ready-to-connect.exception.ts

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
import { DomainException } from 'src/shared'
export class CallAlreadyReadyToConnectException extends DomainException {
constructor() {
super({
key: 'callAlreadyReadyToConnect',
description: 'User already set ready to connect',
})
}
}

10
src/domain/calls/exceptions/call-answered.exception.ts

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
import { DomainException } from 'src/shared'
export class CallAnsweredException extends DomainException {
constructor() {
super({
key: 'callAnswered',
description: 'Call answered',
})
}
}

10
src/domain/calls/exceptions/call-not-found.exception.ts

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
import { DomainException } from 'src/shared'
export class CallNotFoundException extends DomainException {
constructor() {
super({
key: 'callNotFound',
description: 'Call not found',
})
}
}

8
src/domain/calls/exceptions/index.ts

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
export * from './access-call-wrong.exception'
export * from './call-alerady-finished.exception'
export * from './call-already-ready-to-connect.exception'
export * from './call-answered.exception'
export * from './call-not-found.exception'
export * from './initiator-in-call.exception'
export * from './target-user-in-call.exception'
export * from './wrong-device.exception'

10
src/domain/calls/exceptions/initiator-in-call.exception.ts

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
import { DomainException } from 'src/shared'
export class InitiatorInCallException extends DomainException {
constructor() {
super({
key: 'initiatorInCall',
description: 'Initiator in call',
})
}
}

10
src/domain/calls/exceptions/target-user-in-call.exception.ts

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
import { DomainException } from 'src/shared'
export class TargetUserInCallException extends DomainException {
constructor() {
super({
key: 'targetUserInCall',
description: 'Target user in call',
})
}
}

10
src/domain/calls/exceptions/wrong-device.exception.ts

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
import { DomainException } from 'src/shared'
export class WrongDeviceException extends DomainException {
constructor() {
super({
key: 'wrongDevice',
description: 'Call was started on another device',
})
}
}

89
src/domain/calls/services/calls-actions-factory.ts

@ -0,0 +1,89 @@ @@ -0,0 +1,89 @@
import { Inject, Injectable } from '@nestjs/common'
import {
CALLS_LOGS_REPOSITORY,
CALLS_MEMBERS_REPOSITORY,
CALLS_REPOSITORY,
CallsLogsRepository,
CallsMembersRepository,
CallsRepository,
} from '../typing'
import { StartCall } from '../core/start-call'
import { CallAction } from '../core/call-action'
import { Notifications, WebSockets } from 'src/core'
import { NOTIFICATIONS_SERVICE, REAL_TIME_SERVICE } from 'src/core/consts'
import { UsersRepository } from 'src/domain/users/repositories'
import { USERS_REPOSITORY } from 'src/domain/users/consts'
import { FinishCall } from '../core/finish-call'
import { AnswerCall } from '../core/answer-call'
import { ReadyToConnect } from '../core/ready-to-connect'
import { SendRTCSession } from '../core/send-rtc-session'
import { SendIceCandidates } from '../core/send-ice-candidates'
import { OnConnected } from '../core/on-connected'
import { UpdateMediaSettings } from '../core/update-media-settings'
@Injectable()
export class CallsActionsFactory {
constructor(
@Inject(CALLS_REPOSITORY)
private readonly callsRepository: CallsRepository,
@Inject(CALLS_MEMBERS_REPOSITORY)
private readonly callsMembersRepository: CallsMembersRepository,
@Inject(CALLS_LOGS_REPOSITORY)
private readonly callsLogsRepository: CallsLogsRepository,
@Inject(REAL_TIME_SERVICE)
private readonly realTimeService: WebSockets.Service,
@Inject(USERS_REPOSITORY)
private readonly usersRepository: UsersRepository,
@Inject(NOTIFICATIONS_SERVICE)
private readonly notificationsService: Notifications.INotificationsService,
) {}
public startCall() {
return this.injectDependencies(new StartCall())
}
public finishCall() {
return this.injectDependencies(new FinishCall())
}
public answerCall() {
return this.injectDependencies(new AnswerCall())
}
public readyToConnect() {
return this.injectDependencies(new ReadyToConnect())
}
public sendRTCSession() {
return this.injectDependencies(new SendRTCSession())
}
public sendIceCandidates() {
return this.injectDependencies(new SendIceCandidates())
}
public onConnected() {
return this.injectDependencies(new OnConnected())
}
public updateMediaSettings() {
return this.injectDependencies(new UpdateMediaSettings())
}
private injectDependencies<T extends CallAction>(obj: T) {
obj.setRepositories(
this.callsRepository,
this.callsLogsRepository,
this.callsMembersRepository,
)
obj.setUsersRepositories(this.usersRepository)
obj.setRealTimeService(this.realTimeService)
obj.setNotificationsService(this.notificationsService)
return obj
}
}

59
src/domain/calls/services/calls-read.service.ts

@ -0,0 +1,59 @@ @@ -0,0 +1,59 @@
import { Inject, Injectable } from '@nestjs/common'
import {
CALLS_MEMBERS_REPOSITORY,
CALLS_REPOSITORY,
CallsMembersRepository,
CallsRepository,
} from '../typing'
import { IPagination } from 'src/core/interfaces'
import { paginateAndGetMany } from 'src/shared'
@Injectable()
export class CallsReadService {
constructor(
@Inject(CALLS_REPOSITORY)
private readonly callsRepository: CallsRepository,
@Inject(CALLS_MEMBERS_REPOSITORY)
private readonly callsMembersRepository: CallsMembersRepository,
) {}
public async getCall(callId: string, userId: number) {
const call = await this.callsRepository.findOne({
where: { id: callId },
relations: ['members'],
})
if (!call) {
return null
}
const isMember = call?.members?.find(it => it.userId === userId)
if (!isMember) {
return null
}
return call
}
public async getList(userId: number, pagination: IPagination) {
const query = this.callsMembersRepository
.createQueryBuilder('it')
.leftJoinAndSelect('it.call', 'call')
.orderBy('call.createdAt', 'DESC')
.where('it.userId = :userId', { userId })
.andWhere('NOT :userId = ANY(call.hideForUsers)', { userId })
const { items, count } = await paginateAndGetMany(query, pagination, 'it')
for await (const [index, item] of items.entries()) {
const members = await this.callsMembersRepository.find({ callId: item.callId })
items[index].call.members = members
}
return {
items: items.map(it => it.call),
count,
}
}
}

88
src/domain/calls/services/calls.service.ts

@ -0,0 +1,88 @@ @@ -0,0 +1,88 @@
import { Inject, Injectable } from '@nestjs/common'
import { CallsActionsFactory } from './calls-actions-factory'
import {
CALLS_REPOSITORY,
CallsRepository,
IAnswerCallPayload,
ICallEntity,
ICallsService,
IFinishCallPayload,
IOnConnectedPayload,
IReadyToConnectPayload,
IStartCallPayload,
SendIceCandidatesPayload,
SendRTCSessionPayload,
UpdateMediaSettingsPayload,
} from '../typing'
@Injectable()
export class CallsService implements ICallsService {
constructor(
@Inject(CALLS_REPOSITORY)
private readonly callsRepository: CallsRepository,
private readonly callsActionsFactory: CallsActionsFactory,
) {}
public async start(payload: IStartCallPayload): Promise<ICallEntity> {
const startCall = this.callsActionsFactory.startCall()
return await startCall.start(payload)
}
public async answer(payload: IAnswerCallPayload): Promise<void> {
const answerCall = this.callsActionsFactory.answerCall()
await answerCall.answer(payload)
}
public async readyToConnect(payload: IReadyToConnectPayload): Promise<void> {
const readyConnect = this.callsActionsFactory.readyToConnect()
await readyConnect.set(payload)
}
public async finish(payload: IFinishCallPayload): Promise<void> {
const finishCall = this.callsActionsFactory.finishCall()
await finishCall.finish(payload)
}
public async sendRTCSessionDescription(payload: SendRTCSessionPayload) {
const sendRTCSession = this.callsActionsFactory.sendRTCSession()
await sendRTCSession.send(payload)
}
public async sendIceCandidates(payload: SendIceCandidatesPayload) {
const sendIceCandidate = this.callsActionsFactory.sendIceCandidates()
await sendIceCandidate.send(payload)
}
public async onConnected(payload: IOnConnectedPayload) {
const onConnected = this.callsActionsFactory.onConnected()
await onConnected.handle(payload)
}
public async updateMediaSettings(payload: UpdateMediaSettingsPayload) {
const action = this.callsActionsFactory.updateMediaSettings()
await action.update(payload)
}
public async deleteHistoryItem(userId: number, callId: string) {
const call = await this.callsRepository.findOne(callId)
if (!call) return null
call.hideForUsers.push(userId)
if (call.hideForUsers.length === 2) {
await this.callsRepository.delete(callId)
} else {
await this.callsRepository.save(call)
}
return
}
}

150
src/domain/calls/typing/index.ts

@ -0,0 +1,150 @@ @@ -0,0 +1,150 @@
import { Users } from 'src/core'
import { Repository } from 'typeorm'
export enum CallStatus {
Calling = 'calling',
Connection = 'connection',
InProgress = 'inProgress',
Finished = 'finished',
}
export enum CallFinishedReason {
Canceled = 'canceled',
Rejected = 'rejected',
FailedConnect = 'failedConnect',
EndCall = 'endcall',
}
export interface ICallEntity {
id: string
initiatorUserId: number
finishedByUserId?: number
finishedAt: string
startAt: string
createdAt: string
updatedAt: string
status: CallStatus
finishedReason?: CallFinishedReason
members?: ICallMemberEntity[]
accessToken?: string
hideForUsers?: number[]
}
export enum CallMemberStatus {
OutgoingCall = 'outgoingCall',
IncomingCall = 'incomingCall',
Connecting = 'connecting',
Call = 'call',
Closed = 'closed',
}
export interface ICallMemberEntity {
id: number
userId: number
avatarUrl?: string
name: string
deviceUuid: string
callId: string
status: CallMemberStatus
user?: Users.UserModel
call?: ICallEntity
micronOn?: boolean
speakerOn?: boolean
cameraOn?: boolean
}
export interface ICallLogEntity {
id: number
callId: string
description: string
userId?: number
createdAt: string
level?: string
}
export const CALLS_REPOSITORY = Symbol('CALLS_REPOSITORY')
export const CALLS_MEMBERS_REPOSITORY = Symbol('CALLS_MEMBERS_REPOSITORY')
export const CALLS_LOGS_REPOSITORY = Symbol('CALLS_LOGS_REPOSITORY')
export type CallsRepository = Repository<ICallEntity>
export type CallsMembersRepository = Repository<ICallMemberEntity>
export type CallsLogsRepository = Repository<ICallLogEntity>
export interface IStartCallPayload {
initiatorId: number
targetId: number
deviceUuid: string
}
export interface IFinishCallPayload {
userId: number
callId: string
reason: CallFinishedReason
}
export interface IAnswerCallPayload {
userId: number
callId: string
deviceUuid: string
}
export interface IReadyToConnectPayload {
userId: number
callId: string
deviceUuid: string
}
export interface IOnConnectedPayload {
userId: number
callId: string
}
export interface ICallsService {
start(payload: IStartCallPayload): Promise<ICallEntity>
answer(payload: IAnswerCallPayload): Promise<void>
readyToConnect(payload: IReadyToConnectPayload): Promise<void>
finish(payload: IFinishCallPayload): Promise<void>
}
export enum CallSocketEvent {
Answered = '/v2call/answered',
ReadyToConnect = '/v2call/ready-to-connect',
Finished = '/v2call/finished',
RTCSessionDescription = '/v2call/rtc-session-description',
RTCIceCandidates = '/v2call/rtc-ice-candidates',
CallUpdated = '/v2call/call-updated',
}
export interface SendRTCSessionPayload {
userId: number
callId: string
sessionDescription: any
}
export interface SendIceCandidatesPayload {
userId: number
callId: string
iceCandidates: any[]
}
export interface UpdateMediaSettingsPayload {
userId: number
callId: string
micronOn?: boolean
speakerOn?: boolean
cameraOn?: boolean
}

178
src/domain/chats/actions/get-chats-list.action.ts

@ -9,29 +9,39 @@ import { transformFileUrl } from 'src/shared/transforms' @@ -9,29 +9,39 @@ import { transformFileUrl } from 'src/shared/transforms'
import { Brackets, SelectQueryBuilder } from 'typeorm'
import { CHATS_MEMBERS_REPOSITORY, CHATS_MESSAGES_REPOSITORY, CHATS_REPOSITORY } from '../consts'
import { IChatsMembersRepository, IChatsMessagesRepository, IChatsRepository } from '../interfaces'
import { ChatsFixedService } from '../services'
import { ChatsCacheService, ChatsFixedService } from '../services'
import { ChatsAccessoryService } from '../services/chats-accessory.service'
import { ChatsSecretService } from '../services/chats-secret.service'
@Injectable()
export class GetChatsListAction extends Action implements Chats.IGetChatsListAction {
@Inject(CHATS_REPOSITORY) private readonly chatsRepository: IChatsRepository
@Inject(CHATS_MEMBERS_REPOSITORY)
private readonly chatsMembersRepository: IChatsMembersRepository
constructor(
@Inject(CHATS_REPOSITORY)
private readonly chatsRepository: IChatsRepository,
@Inject(CHATS_MESSAGES_REPOSITORY)
private readonly chatsMessagesRepository: IChatsMessagesRepository
@Inject(USERS_SERVICE) private readonly usersService: Users.IUsersService
@Inject(CHATS_MEMBERS_REPOSITORY)
private readonly chatsMembersRepository: IChatsMembersRepository,
@Inject(CHATS_MESSAGES_REPOSITORY)
private readonly chatsMessagesRepository: IChatsMessagesRepository,
@Inject(USERS_SERVICE)
private readonly usersService: Users.IUsersService,
constructor(
private readonly chatsFixedService: ChatsFixedService,
private readonly chatsAccessoryService: ChatsAccessoryService,
private readonly chatsCacheService: ChatsCacheService,
private readonly chatsSecretService: ChatsSecretService,
) {
super()
}
public async run(userId: number, pagination: Readonly<IPagination>) {
public async run(userId: number, pagination: Readonly<IPagination>, forceCache = false) {
try {
let chatsIds = await this.getChatsIdsByUserId(userId)
const hiddenChatIds = await this.chatsSecretService.getHiddenChatIds(userId)
let chatsIds = await this.getChatsIdsByUserId(userId, hiddenChatIds)
const count = chatsIds.length
const _pagination = { ...pagination }
const chats: Array<Chats.IChat> = []
@ -40,6 +50,7 @@ export class GetChatsListAction extends Action implements Chats.IGetChatsListAct @@ -40,6 +50,7 @@ export class GetChatsListAction extends Action implements Chats.IGetChatsListAct
userId,
chatsIds,
_pagination.searchString,
hiddenChatIds,
)
const ids = fixedItems.map(it => Number(it.id))
chatsIds = chatsIds.filter(it => !ids.includes(it))
@ -59,7 +70,7 @@ export class GetChatsListAction extends Action implements Chats.IGetChatsListAct @@ -59,7 +70,7 @@ export class GetChatsListAction extends Action implements Chats.IGetChatsListAct
const { items } = await paginateAndGetMany(query, _pagination, 'it')
chats.push(...items)
await this.fillAndTransformChatsList(chats, userId)
await this.fillAndTransformChatsList(chats, userId, forceCache)
return { items: chats, count }
} catch (e) {
@ -67,12 +78,19 @@ export class GetChatsListAction extends Action implements Chats.IGetChatsListAct @@ -67,12 +78,19 @@ export class GetChatsListAction extends Action implements Chats.IGetChatsListAct
}
}
private async getChatsIdsByUserId(userId: number) {
const members = await this.chatsMembersRepository
private async getChatsIdsByUserId(userId: number, execludeIds: number[] = []) {
const query = this.chatsMembersRepository
.createQueryBuilder('it')
.distinct(true)
.select('it.chatId', 'chatId')
.where('it.userId = :userId', { userId })
.andWhere('it.isDeleted = :isDeleted', { isDeleted: false })
.getMany()
if (Array.isArray(execludeIds) && execludeIds.length) {
query.andWhere('it.chatId NOT IN (:...ids)', { ids: execludeIds })
}
const members = await query.getRawMany()
return members.map(it => it.chatId)
}
@ -81,10 +99,11 @@ export class GetChatsListAction extends Action implements Chats.IGetChatsListAct @@ -81,10 +99,11 @@ export class GetChatsListAction extends Action implements Chats.IGetChatsListAct
userId: number,
includexChatsIds?: number[], // id всіх активних чатів користувача
searchString?: string,
execludeIds?: number[],
) {
try {
// всі закріплені чати користувача
let fixedItems = await this.chatsFixedService.getFixed(userId)
let fixedItems = await this.chatsFixedService.getFixed(userId, execludeIds)
// закріплені чати, яких нема серед активних чатів користувача (тобто він більше не учасник цих чатів)
const inactiveFixed = fixedItems.filter(it => !includexChatsIds.includes(it.chatId))
@ -174,75 +193,94 @@ export class GetChatsListAction extends Action implements Chats.IGetChatsListAct @@ -174,75 +193,94 @@ export class GetChatsListAction extends Action implements Chats.IGetChatsListAct
return personalChatsMembers.map(it => it.chatId)
}
private async fillAndTransformChatsList(chats: Chats.IChat[], userId: number) {
private async fillAndTransformChatsList(
chats: Chats.IChat[],
userId: number,
forceCache: boolean,
) {
await Promise.all(
chats.map(async (chat, i) => {
chat.lastMessage = await this.getLastMessage(chat.id, userId)
chat.isChatMuted = await this.chatsAccessoryService.getIsChatMuted(chat.id, userId)
const member = await this.chatsAccessoryService.findMemberAndGetData(
chat.id,
userId,
['addedAt'],
)
chat.firstMessageId = await this.chatsAccessoryService.getChatFirstMessageId({
chatId: chat.id,
userId,
order: 'ASC',
fromDate: member.addedAt,
})
chat.lastMessageId = await this.chatsAccessoryService.getChatFirstMessageId({
chatId: chat.id,
userId,
order: 'DESC',
fromDate: member.addedAt,
})
chat.unreadMessagesCount = (await this.chatsAccessoryService.getChatUnreadMessages(
chat.id,
userId,
true,
member.addedAt,
)) as number
chat.isChatUnread = chat.unreadBy
? _.includes(JSON.parse(chat.unreadBy), userId)
: false
delete chat.unreadBy
if (chat.type === Chats.ChatType.Personal) {
// *** Якщо чат персональний, то до даних чату прикріплюються дані співбесідника,
// щоб можна було відобразити чат в списку чатів
const usersIds = await this.chatsAccessoryService.getChatUsersIds(chat.id)
const targetUserId = _.find(usersIds, it => it !== userId)
chats[i].chatMembers = (await this.chatsAccessoryService.getChatMembersList(
chat.id,
userId,
targetUserId,
)) as any
chats.map(async (chat, index) => {
const cached = forceCache ? null : await this.getFromCache(chat, userId)
if (cached) {
chats[index] = cached
} else {
const members = await this.chatsAccessoryService.getChatMembersList(chat.id)
chats[i].chatMembers = members.map(it =>
_.pick(it, ['id', 'role', 'chatId', 'userId', 'isDeleted']),
) as any
}
const filledChat = await this.fillAndTransformChat(chat, userId)
if (chat.type === Chats.ChatType.Group)
chat.role = await this.chatsAccessoryService.getUserRoleInChat(userId, chat.id)
chats[index] = filledChat
if (chat.previewUrl) chat.previewUrl = transformFileUrl(chat.previewUrl)
this.chatsCacheService.save(filledChat, userId)
}
}),
)
}
private async getFromCache(chat: Chats.IChat, userId: number): Promise<Chats.IChat> {
const cached = await this.chatsCacheService.getFromCache(chat.id, userId, false)
return cached
}
private async fillAndTransformChat(chat: Chats.IChat, userId: number) {
const [_, currentUser] = await this.fillChatMembers(chat, userId)
chat.lastMessage = await this.getLastMessage(chat.id, userId)
chat.isChatMuted = currentUser.isChatMuted
if (chat.type === Chats.ChatType.Group) chat.role = currentUser.role
chat.firstMessageId = await this.chatsAccessoryService.getChatFirstMessageId({
chatId: chat.id,
userId,
order: 'ASC',
fromDate: currentUser.addedAt,
})
chat.lastMessageId = chat.lastMessage?.id
chat.unreadMessagesCount = (await this.chatsAccessoryService.getChatUnreadMessages(
chat.id,
userId,
true,
currentUser.addedAt,
)) as number
chat.isChatUnread = chat.unreadBy ? _.includes(JSON.parse(chat.unreadBy), userId) : false
delete chat.unreadBy
if (chat.previewUrl) chat.previewUrl = transformFileUrl(chat.previewUrl)
return chat
}
private async fillChatMembers(
chat: Chats.IChat,
userId: number,
): Promise<[Chats.IChatMember[], Chats.IChatMember]> {
const members = await this.chatsMembersRepository
.createQueryBuilder('it')
.where('it.chatId = :chatId', { chatId: chat.id })
.getMany()
if (chat.type === Chats.ChatType.Personal) {
const [targetMember] = members.filter(it => it.userId !== userId)
chat.chatMembers = await this.chatsAccessoryService.fillMembers([targetMember])
} else {
chat.chatMembers = members.map(it =>
_.pick(it, ['id', 'role', 'chatId', 'userId', 'isDeleted']),
) as any
}
const currentUser = members.find(it => it.userId === userId)
return [members, currentUser]
}
private async getLastMessage(chatId: number, userId: number) {
const member = await this.chatsMembersRepository.findOne({ chatId, userId })
const query = this.chatsMessagesRepository
.createQueryBuilder('it')
.andWhere('it.chatId = :chatId', { chatId })
.leftJoinAndSelect('it.events', 'events')
.leftJoinAndSelect(
'it.deleted',
'deleted',

15
src/domain/chats/chats.module.ts

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { DynamicModule, Module } from '@nestjs/common'
import {
CHATS_FIXED_SERVICE,
CHATS_FORWARD_MESSAGES_SERVICE,
CHATS_MEMBERS_SERVICE,
CHATS_MESSAGES_SERVICE,
CHATS_SERVICE,
@ -8,7 +9,7 @@ import { @@ -8,7 +9,7 @@ import {
GET_CHATS_LIST_ACTION,
} from 'src/core/consts'
import { FilesStorageModule, provideEntity } from 'src/libs'
import { FilesStorageModule, RedisModule, provideEntity } from 'src/libs'
import { provideClass } from 'src/shared'
import { RealTimeModule } from '../real-time/real-time.module'
import { UsersModule } from '../users/users.module'
@ -40,7 +41,11 @@ import { @@ -40,7 +41,11 @@ import {
ChatsService,
ChatsSystemMessagesService,
CHATS_SERVICES,
ChatsForwardMessagesService,
} from './services'
import { CHATS_LISTENERS } from './listeners'
import { SecretModModule } from '../secret-mod/secret-mod.module'
import { ChatsSecretService } from './services/chats-secret.service'
@Module({})
export class ChatsModule {
@ -60,6 +65,8 @@ export class ChatsModule { @@ -60,6 +65,8 @@ export class ChatsModule {
provideClass(CHATS_FIXED_SERVICE, ChatsFixedService),
provideClass(CHATS_SYSTEM_MESSAGES_SERVICE, ChatsSystemMessagesService),
provideClass(GET_CHATS_LIST_ACTION, GetChatsListAction),
provideClass(CHATS_FORWARD_MESSAGES_SERVICE, ChatsForwardMessagesService),
ChatsSecretService,
{
provide: MESSAGES_CRYPT_SALT,
useValue: ChatsModule.options.messagesCryptSalt,
@ -77,6 +84,7 @@ export class ChatsModule { @@ -77,6 +84,7 @@ export class ChatsModule {
CHATS_FIXED_SERVICE,
CHATS_SYSTEM_MESSAGES_SERVICE,
GET_CHATS_LIST_ACTION,
CHATS_FORWARD_MESSAGES_SERVICE,
]
}
@ -85,6 +93,8 @@ export class ChatsModule { @@ -85,6 +93,8 @@ export class ChatsModule {
UsersModule.forFeature(),
FilesStorageModule.forFeature(),
RealTimeModule.forFeature(),
RedisModule.forFeature(),
SecretModModule.forFeature(),
]
}
@ -93,6 +103,9 @@ export class ChatsModule { @@ -93,6 +103,9 @@ export class ChatsModule {
return {
module: ChatsModule,
providers: [...ChatsModule.getProviders(), ...CHATS_LISTENERS],
imports: ChatsModule.getImports(),
exports: ChatsModule.getExports(),
}
}

3
src/domain/chats/entities/chat-message.entity.ts

@ -44,6 +44,9 @@ export class ChatMessage implements Chats.IChatMessage { @@ -44,6 +44,9 @@ export class ChatMessage implements Chats.IChatMessage {
@OneToMany(() => ChatMessageDeleted, deleted => deleted.message)
deleted: ChatMessageDeleted[]
@Column({ nullable: true })
uniqueKey: string
@CreateDateColumn({ type: 'timestamp', default: () => 'LOCALTIMESTAMP' })
createdAt: string
}

15
src/domain/chats/listeners/index.ts

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
import { OnChatMessageViewListener } from './on-chat-message-view.listener'
import { OnChatNewMessageListener } from './on-chat-new-message.listener'
import { OnNewChatListener } from './on-new-chat.listener'
import { OnReadChatListener } from './on-read-chat.listener'
import { OnSecretModTurnListener } from './on-secret-mod-turn.listener'
import { OnSecretRecordsChangedListener } from './on-secret-records-changed.listener'
export const CHATS_LISTENERS = [
OnReadChatListener,
OnChatMessageViewListener,
OnChatNewMessageListener,
OnNewChatListener,
OnSecretModTurnListener,
OnSecretRecordsChangedListener,
]

29
src/domain/chats/listeners/on-chat-message-view.listener.ts

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common'
import { OnEvent } from '@nestjs/event-emitter'
import { Events, IEventsPayloads } from 'src/core/enums'
import { ChatsEventsService } from '../services/chats-events.service'
import { Chats } from 'src/core'
import * as _ from 'lodash'
@Injectable()
export class OnChatMessageViewListener {
constructor(private readonly chatsEventsService: ChatsEventsService) {}
@OnEvent(Events.OnChatMessageView)
public async onChatMessageView(payload: IEventsPayloads['OnChatMessageView']) {
const existEvents = await this.chatsEventsService.getExistEvents(
payload.messageIds,
payload.userId,
Chats.ChatMessageEventType.View,
)
for (const id of payload.messageIds) {
if (!_.find(existEvents, event => event.messageId === id))
await this.chatsEventsService.addEvent({
messageId: id,
userId: payload.userId,
type: Chats.ChatMessageEventType.View,
})
}
}
}

15
src/domain/chats/listeners/on-chat-new-message.listener.ts

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common'
import { OnEvent } from '@nestjs/event-emitter'
import { Events, IEventsPayloads } from 'src/core/enums'
import * as _ from 'lodash'
import { ChatsCacheService } from '../services'
@Injectable()
export class OnChatNewMessageListener {
constructor(private readonly chatsCacheService: ChatsCacheService) {}
@OnEvent(Events.OnNewMessage)
public async removeCache(payload: IEventsPayloads['OnNewMessage']) {
await this.chatsCacheService.remove(payload.chatId)
}
}

15
src/domain/chats/listeners/on-chat-update.listener.ts

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common'
import { OnEvent } from '@nestjs/event-emitter'
import { Events, IEventsPayloads } from 'src/core/enums'
import * as _ from 'lodash'
import { ChatsCacheService } from '../services'
@Injectable()
export class OnChatNewMessageListener {
constructor(private readonly chatsCacheService: ChatsCacheService) {}
@OnEvent(Events.OnNewMessage)
public async removeCache(payload: IEventsPayloads['OnNewMessage']) {
await this.chatsCacheService.remove(payload.chatId)
}
}

47
src/domain/chats/listeners/on-new-chat.listener.ts

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
import { Inject, Injectable } from '@nestjs/common'
import { OnEvent } from '@nestjs/event-emitter'
import { Events, IEventsPayloads } from 'src/core/enums'
import * as _ from 'lodash'
import { SECRET_MOD_SERVICE } from 'src/domain/secret-mod/consts'
import { CHATS_MEMBERS_REPOSITORY } from '../consts'
import { IChatsMembersRepository } from '../interfaces'
import { SecretMod } from 'src/domain/secret-mod/typing'
import { ChatsSecretService } from '../services/chats-secret.service'
@Injectable()
export class OnNewChatListener {
constructor(
@Inject(SECRET_MOD_SERVICE)
private readonly secretModService: SecretMod.Service,
@Inject(CHATS_MEMBERS_REPOSITORY)
private readonly chatsMembersRepository: IChatsMembersRepository,
private readonly chatsSecretService: ChatsSecretService,
) {}
@OnEvent(Events.OnNewChat)
public async checkForHiddenUsers(payload: IEventsPayloads['OnNewChat']) {
const dbResult = await this.chatsMembersRepository
.createQueryBuilder('it')
.distinct(true)
.select('it.userId', 'userId')
.where('it.chatId = :chatId', { chatId: payload.chatId })
.getRawMany()
const usersIds = dbResult.map(it => Number(it.userId))
const hiddenUsersIds = await this.secretModService.getHiddenUsersIds()
const hasIntersections = usersIds.reduce((result, it) => {
if (result === true) return true
if (hiddenUsersIds.includes(it)) {
return true
}
return false
}, false)
if (!hasIntersections) return
this.chatsSecretService.allocateSecretChats()
}
}

43
src/domain/chats/listeners/on-read-chat.listener.ts

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common'
import { ChatsEventsService } from '../services/chats-events.service'
import { OnEvent } from '@nestjs/event-emitter'
import { Events, IEventsPayloads } from 'src/core/enums'
import { Chats } from 'src/core'
import { ChatsAccessoryService } from '../services/chats-accessory.service'
import * as _ from 'lodash'
import { ChatsCacheService } from '../services'
@Injectable()
export class OnReadChatListener {
constructor(
private readonly chatsEventsService: ChatsEventsService,
private readonly chatsAccessoryService: ChatsAccessoryService,
private readonly chatsCacheService: ChatsCacheService,
) {}
@OnEvent(Events.OnReadChat)
public async addEvemts(payload: IEventsPayloads['OnReadChat']) {
const unreadMessages = (await this.chatsAccessoryService.getChatUnreadMessages(
payload.chatId,
payload.userId,
false,
)) as Chats.IChatMessage[]
if (_.isEmpty(unreadMessages)) return
const messagesIds = unreadMessages.map(it => it.id)
for (const id of messagesIds) {
await this.chatsEventsService.addEvent({
messageId: id,
userId: payload.userId,
type: Chats.ChatMessageEventType.View,
})
}
}
@OnEvent(Events.OnReadChat)
public async clearCache(payload: IEventsPayloads['OnReadChat']) {
await this.chatsCacheService.remove(payload.chatId)
}
}

19
src/domain/chats/listeners/on-secret-mod-turn.listener.ts

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common'
import { OnEvent } from '@nestjs/event-emitter'
import { Events, IEventsPayloads } from 'src/core/enums'
import * as _ from 'lodash'
import { ChatsSecretService } from '../services/chats-secret.service'
@Injectable()
export class OnSecretModTurnListener {
constructor(private readonly chatsSecretService: ChatsSecretService) {}
@OnEvent(Events.OnSecretModTurn)
public async addEvemts(payload: IEventsPayloads['OnSecretModTurn']) {
if (payload.isActive) {
await this.chatsSecretService.allocateSecretChats()
} else {
await this.chatsSecretService.clear()
}
}
}

15
src/domain/chats/listeners/on-secret-records-changed.listener.ts

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common'
import { OnEvent } from '@nestjs/event-emitter'
import { Events } from 'src/core/enums'
import * as _ from 'lodash'
import { ChatsSecretService } from '../services/chats-secret.service'
@Injectable()
export class OnSecretRecordsChangedListener {
constructor(private readonly chatsSecretService: ChatsSecretService) {}
@OnEvent(Events.OnSecretRecordsChanged)
public async addEvemts() {
await this.chatsSecretService.allocateSecretChats()
}
}

73
src/domain/chats/services/chats-accessory.service.ts

@ -16,19 +16,31 @@ import { Events } from 'src/core/enums' @@ -16,19 +16,31 @@ import { Events } from 'src/core/enums'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { Brackets } from 'typeorm'
import { setTimeout } from 'timers'
import { ChatsSecretService } from './chats-secret.service'
@Injectable()
export class ChatsAccessoryService {
constructor(
@Inject(CHATS_MEMBERS_REPOSITORY)
private readonly chatsMembersRepository: IChatsMembersRepository,
@Inject(CHATS_MESSAGES_REPOSITORY)
private readonly chatsMessagesRepository: IChatsMessagesRepository,
@Inject(USERS_SERVICE) private readonly usersService: Users.IUsersService,
@Inject(REAL_TIME_SERVICE) private wsService: WebSockets.Service,
@Inject(CHATS_REPOSITORY) private readonly chatsRepository: IChatsRepository,
@Inject(USERS_SERVICE)
private readonly usersService: Users.IUsersService,
@Inject(REAL_TIME_SERVICE)
private readonly wsService: WebSockets.Service,
@Inject(CHATS_REPOSITORY)
private readonly chatsRepository: IChatsRepository,
private readonly chatsCryptService: ChatsCryptService,
private eventEmitter: EventEmitter2,
private readonly eventEmitter: EventEmitter2,
private readonly chatsSecretService: ChatsSecretService,
) {}
// ******* CHATS *********************
@ -56,7 +68,7 @@ export class ChatsAccessoryService { @@ -56,7 +68,7 @@ export class ChatsAccessoryService {
public async afterSendMessage(
chatId: number,
message: Chats.IChatMessage & { offlineKey?: string },
message: Chats.IChatMessage & { offlineKey?: string; uniqueKey?: string },
chatType?: Chats.ChatType,
offlineKey?: string,
) {
@ -79,7 +91,7 @@ export class ChatsAccessoryService { @@ -79,7 +91,7 @@ export class ChatsAccessoryService {
if (offlineKey)
setTimeout(() => {
this.wsService.emitToUser(message.userId, 'chat/new-message-offline', data)
}, 5000)
}, 1000)
}
public async afterUpdateMessage(chatId: number, message: Chats.IChatMessage) {
@ -170,26 +182,30 @@ export class ChatsAccessoryService { @@ -170,26 +182,30 @@ export class ChatsAccessoryService {
return chatBetweenUsers
}
public async getChatName(chatId: number, userId: number) {
public async getChatName(chatId: number, userId: number, revert = false) {
const chat = await this.selectChatData(chatId, ['type', 'name'])
if (chat.type === Chats.ChatType.Personal)
return this.getPersonalChatUserName(chatId, userId)
return this.getPersonalChatUserName(chatId, userId, revert)
if (chat.type === Chats.ChatType.Group && chat.name) return chat.name
return chatId
}
public async getPersonalChatUserName(chatId: number, userId: number) {
const members = await this.chatsMembersRepository
.createQueryBuilder('it')
.select('it.userId')
.where('it.chatId = :chatId', { chatId })
.getMany()
public async getPersonalChatUserName(chatId: number, userId: number, revert = false) {
let targetUserId = userId
const chatUsersIds = members.map(it => it.userId)
const targetUserId = _.find(chatUsersIds, id => id !== userId)
if (!targetUserId) return chatId
if (!revert) {
const members = await this.chatsMembersRepository
.createQueryBuilder('it')
.select('it.userId')
.where('it.chatId = :chatId', { chatId })
.getMany()
const chatUsersIds = members.map(it => it.userId)
targetUserId = _.find(chatUsersIds, id => id !== userId)
if (!targetUserId) return chatId
}
return this.getUserName(targetUserId)
}
@ -325,7 +341,7 @@ export class ChatsAccessoryService { @@ -325,7 +341,7 @@ export class ChatsAccessoryService {
return members
}
private async fillMembers(members: Chats.IChatMember[]) {
public async fillMembers(members: Chats.IChatMember[]) {
await Promise.all(
members.map(async it => {
const user = await this.usersService.getOne(it.userId, ['info'])
@ -341,6 +357,7 @@ export class ChatsAccessoryService { @@ -341,6 +357,7 @@ export class ChatsAccessoryService {
if (it.user.avatarUrl) it.user.avatarUrl = transformFileUrl(it.user.avatarUrl)
}),
)
return members
}
public async pushNotificationToChatMembers(
@ -349,7 +366,8 @@ export class ChatsAccessoryService { @@ -349,7 +366,8 @@ export class ChatsAccessoryService {
userId: number,
toUserIds?: number[],
) {
const chatName = await this.getChatName(chatId, userId)
const isHidden = await this.chatsSecretService.isChatHidden(chatId)
const chatName = await this.getChatName(chatId, userId, true)
const chatMembers = await this.chatsMembersRepository
.createQueryBuilder('it')
@ -360,9 +378,12 @@ export class ChatsAccessoryService { @@ -360,9 +378,12 @@ export class ChatsAccessoryService {
const chatUsersIds = chatMembers.map(it => it.userId)
const targetUserIds = toUserIds ? toUserIds : _.without(chatUsersIds, userId)
const membersMutedChat = _.filter(chatMembers, member => member.isChatMuted)
const membersMutedChat = isHidden
? chatMembers
: _.filter(chatMembers, member => member.isChatMuted)
const usersMutedChat = membersMutedChat.map(it => it.userId)
console.log('Send event', event, usersMutedChat, targetUserIds)
this.eventEmitter.emit(event, {
chatName,
targetUserIds,
@ -532,7 +553,6 @@ export class ChatsAccessoryService { @@ -532,7 +553,6 @@ export class ChatsAccessoryService {
const query = this.chatsMessagesRepository
.createQueryBuilder('it')
.andWhere('it.chatId = :chatId', { chatId })
.leftJoinAndSelect('it.events', 'events')
.leftJoinAndSelect(
'it.deleted',
'deleted',
@ -546,6 +566,7 @@ export class ChatsAccessoryService { @@ -546,6 +566,7 @@ export class ChatsAccessoryService {
const message = await query.getOne()
if (!message) return null
return message.id
}
@ -566,12 +587,14 @@ export class ChatsAccessoryService { @@ -566,12 +587,14 @@ export class ChatsAccessoryService {
'userId',
'isPined',
'createdAt',
'uniqueKey',
])
const decoded = await this.chatsCryptService.decodeMessage(message.content, salt)
prepared.content = JSON.parse(decoded)
if (message.type === Chats.MessageType.Sticker) {
console.log('CHAT DECODE', prepared)
try {
if (decoded && typeof decoded === 'string') prepared.content = JSON.parse(decoded)
} catch (e) {
prepared.content = {}
}
prepared.events = this.transformEvents(message.events)
@ -619,7 +642,7 @@ export class ChatsAccessoryService { @@ -619,7 +642,7 @@ export class ChatsAccessoryService {
await this.addChatAndUser(message.content.originalMessage)
}
private async addChatAndUser(obj: Record<string, any>) {
private async addChatAndUser(obj: Record<string, any> = {}) {
if (obj.chatId) {
try {
const chat = await this.selectChatData(obj.chatId, [

81
src/domain/chats/services/chats-cache.service.ts

@ -0,0 +1,81 @@ @@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common'
import { Chats } from 'src/core'
import { RedisService } from 'src/libs'
@Injectable()
export class ChatsCacheService {
constructor(private readonly redisService: RedisService) {}
public async save(chatItem: Chats.IChat, userId: number) {
await this.redisService.set(
this.getKey(chatItem.id, userId),
JSON.stringify(chatItem),
100000,
)
await this.updateCacheMetadata(chatItem.id, userId)
}
public async getFromCache(chatId: number, userId: number, clearAfterGet = false) {
try {
const key = this.getKey(chatId, userId)
const data = await this.redisService.get(key)
if (!data) return null
const chat = JSON.parse(data)
if (chat.id !== chatId) throw new Error('Invalid cache')
if (clearAfterGet) {
this.redisService.del(key)
}
return chat
} catch (e) {
return null
}
}
public async remove(chatId: number) {
const usersIds = await this.getChatCacheMetadata(chatId)
if (!usersIds.length) {
return null
}
for await (const userId of usersIds) {
await this.redisService.del(this.getKey(chatId, userId))
}
await this.redisService.del(this.getMetadataKey(chatId))
}
private getKey(chatId: number, userId: number) {
return `chat-cache/id:${chatId};userId:${userId}`
}
private async updateCacheMetadata(chatId: number, userId: number) {
const exist = await this.getChatCacheMetadata(chatId)
await this.setCacheMetadata(chatId, [...exist, userId])
}
private async setCacheMetadata(chatId: number, usersIds: number[]) {
await this.redisService.set(this.getMetadataKey(chatId), [...new Set(usersIds)].join(','))
}
private async getChatCacheMetadata(chatId: number): Promise<number[]> {
try {
const exist = await this.redisService.get(this.getMetadataKey(chatId))
if (!exist || typeof exist !== 'string') return []
return exist.split(',').map(Number)
} catch (e) {
return []
}
}
private getMetadataKey(chatId: number) {
return `chat-cache/metadata/id:${chatId}`
}
}

43
src/domain/chats/services/chats-events.service.ts

@ -3,57 +3,14 @@ import { Chats } from 'src/core' @@ -3,57 +3,14 @@ import { Chats } from 'src/core'
import { CHATS_MESSAGES_EVENTS_REPOSITORY } from '../consts'
import { IChatsMessagesEventsRepository } from '../interfaces'
import * as _ from 'lodash'
import { OnEvent } from '@nestjs/event-emitter'
import { Events, IEventsPayloads } from 'src/core/enums'
import { ChatsAccessoryService } from './chats-accessory.service'
@Injectable()
export class ChatsEventsService {
constructor(
@Inject(CHATS_MESSAGES_EVENTS_REPOSITORY)
private readonly chatsMessagesEventsRepository: IChatsMessagesEventsRepository,
private readonly chatsAccessoryService: ChatsAccessoryService,
) {}
@OnEvent(Events.OnChatMessageView)
public async onChatMessageView(payload: IEventsPayloads['OnChatMessageView']) {
const existEvents = await this.getExistEvents(
payload.messageIds,
payload.userId,
Chats.ChatMessageEventType.View,
)
for (const id of payload.messageIds) {
if (!_.find(existEvents, event => event.messageId === id))
await this.addEvent({
messageId: id,
userId: payload.userId,
type: Chats.ChatMessageEventType.View,
})
}
}
@OnEvent(Events.OnReadChat)
public async onReadChat(payload: IEventsPayloads['OnReadChat']) {
const unreadMessages = (await this.chatsAccessoryService.getChatUnreadMessages(
payload.chatId,
payload.userId,
false,
)) as Chats.IChatMessage[]
if (_.isEmpty(unreadMessages)) return
const messagesIds = unreadMessages.map(it => it.id)
for (const id of messagesIds) {
await this.addEvent({
messageId: id,
userId: payload.userId,
type: Chats.ChatMessageEventType.View,
})
}
}
public async addEvent(payload: Chats.IAddChatMessageEventPayload) {
await this.chatsMessagesEventsRepository.save({
messageId: payload.messageId,

12
src/domain/chats/services/chats-fixed.service.ts

@ -71,13 +71,17 @@ export class ChatsFixedService implements Chats.IChatsFixedService { @@ -71,13 +71,17 @@ export class ChatsFixedService implements Chats.IChatsFixedService {
await this.chatsFixedRepository.delete({ chatId }).catch(null)
}
public async getFixed(userId: number) {
const items = await this.chatsFixedRepository
public async getFixed(userId: number, execludeIds: number[] = []) {
const query = this.chatsFixedRepository
.createQueryBuilder('it')
.where('it.userId = :userId', { userId })
.orderBy('it.order', 'ASC')
.getMany()
return items
if (Array.isArray(execludeIds) && execludeIds.length) {
query.andWhere('it.chatId NOT IN (:...ids)', { ids: execludeIds })
}
return await query.getMany()
}
public async getFixedCount(userId: number) {

126
src/domain/chats/services/chats-forward-messages.service.ts

@ -0,0 +1,126 @@ @@ -0,0 +1,126 @@
import { Inject, Injectable } from '@nestjs/common'
import { Chats } from 'src/core'
import { ChatsAccessoryService } from './chats-accessory.service'
import { CHATS_MESSAGES_REPOSITORY } from '../consts'
import { IChatsMessagesRepository } from '../interfaces'
import { ChatsCryptService } from './chats-crypt.service'
import { transformFileUrl } from 'src/shared/transforms'
import * as _ from 'lodash'
@Injectable()
export class ChatsForwardMessagesService implements Chats.IChatForwardService {
constructor(
private readonly chatsAccessoryService: ChatsAccessoryService,
private readonly chatsCryptService: ChatsCryptService,
@Inject(CHATS_MESSAGES_REPOSITORY)
private readonly chatsMessagesRepository: IChatsMessagesRepository,
) {}
public async forwardMessages({ chatId, messagesIds, userId }: Chats.IForwardMessagesPayload) {
const chat = await this.chatsAccessoryService.selectChatData(chatId, ['salt', 'type', 'id'])
await this.chatsAccessoryService.isUserChatMember(chatId, userId)
const originMessages = await this.loadOriginMessages(messagesIds)
let forwardedMessages = await this.saveNewMessages(
originMessages,
chat.salt,
chatId,
userId,
)
forwardedMessages = forwardedMessages.sort((a, b) => a.id - b.id)
await this.chatsAccessoryService.afterSendManyMessages(chatId, forwardedMessages, chat.type)
}
private async loadOriginMessages(messagesIds: number[]) {
const messages = await this.chatsMessagesRepository.findByIds(messagesIds, {
select: ['id', 'type', 'chatId', 'userId', 'isPined', 'createdAt', 'content'],
})
const originChat = await this.chatsAccessoryService.selectChatData(messages[0].chatId, [
'salt',
'type',
])
return await Promise.all(
messages.map(async message => {
return await this.prepareOriginMessage(message, originChat.salt)
}),
)
}
private async prepareOriginMessage(
message: Chats.IChatMessage,
chatSalt: string,
): Promise<Chats.ITransformedMessage> {
const transformedMessage: Chats.ITransformedMessage = {
...message,
content: null,
events: null,
}
const decoded = await this.chatsCryptService.decodeMessage(message.content, chatSalt)
transformedMessage.content = JSON.parse(decoded)
transformedMessage.content = _.omit(transformedMessage.content, 'replyToMessage')
if (this.getFileUrl(transformedMessage)) this.transformUrl(transformedMessage)
return transformedMessage
}
private async saveNewMessages(
originMessages: Chats.ITransformedMessage[],
chatSalt: string,
chatId: number,
userId: number,
) {
const preparedMessages = []
const createdAt = new Date().getTime()
await Promise.all(
originMessages.map(async (item, index) => {
const message = await this.prepareNewMessage(item, chatSalt)
preparedMessages[index] = {
...message,
chatId,
userId,
createdAt: new Date(createdAt + index).toISOString(),
}
}),
)
const result = await this.chatsMessagesRepository.save(preparedMessages)
return result
}
private async prepareNewMessage(originalMessage: Chats.ITransformedMessage, chatSalt: string) {
const messageContent: Record<string, any> = { originalMessage }
const encodedMessage = await this.chatsCryptService.encodeMessage(
JSON.stringify(messageContent),
chatSalt,
)
return {
content: encodedMessage,
type: Chats.MessageType.Forwarded,
salt: chatSalt,
}
}
private transformUrl(message: Chats.ITransformedMessage) {
if (message.content.fileUrl)
message.content.fileUrl = transformFileUrl(message.content.fileUrl)
}
private getFileUrl(message: Chats.ITransformedMessage) {
if (
(message.type === Chats.MessageType.Audio ||
message.type === Chats.MessageType.File ||
message.type === Chats.MessageType.Image ||
message.type === Chats.MessageType.Video) &&
message.content
)
return message.content.fileUrl
}
}

62
src/domain/chats/services/chats-messages.service.ts

@ -12,8 +12,16 @@ import { IPagination } from 'src/core/interfaces' @@ -12,8 +12,16 @@ import { IPagination } from 'src/core/interfaces'
import { paginateAndGetMany } from 'src/shared'
import { transformFileUrl } from 'src/shared/transforms'
import { Brackets, SelectQueryBuilder } from 'typeorm'
import { CHATS_MESSAGES_DELETED_REPOSITORY, CHATS_MESSAGES_REPOSITORY } from '../consts'
import { IChatsMessagesDeletedRepository, IChatsMessagesRepository } from '../interfaces'
import {
CHATS_MESSAGES_DELETED_REPOSITORY,
CHATS_MESSAGES_REPOSITORY,
CHATS_REPOSITORY,
} from '../consts'
import {
IChatsMessagesDeletedRepository,
IChatsMessagesRepository,
IChatsRepository,
} from '../interfaces'
import { ChatsAccessoryService } from './chats-accessory.service'
import { ChatsCryptService } from './chats-crypt.service'
import { ChatsExportService } from './chats-export.service'
@ -26,13 +34,18 @@ export class ChatsMessagesService implements Chats.IChatsMessagesService { @@ -26,13 +34,18 @@ export class ChatsMessagesService implements Chats.IChatsMessagesService {
constructor(
@Inject(CHATS_MESSAGES_REPOSITORY)
private readonly chatsMessagesRepository: IChatsMessagesRepository,
@Inject(CHATS_MESSAGES_DELETED_REPOSITORY)
private readonly chatsMessagesDeletedRepository: IChatsMessagesDeletedRepository,
@Inject(FILES_STORAGE_SERVICE)
private readonly filesStorageService: FilesStorage.IFilesStorageService,
@Inject(CHATS_REPOSITORY)
private readonly chatsRepository: IChatsRepository,
private readonly chatsCryptService: ChatsCryptService,
private readonly chatsAccessoryService: ChatsAccessoryService,
@Inject(REAL_TIME_SERVICE) private wsService: WebSockets.Service,
private readonly chatsExportService: ChatsExportService,
) {}
@ -41,6 +54,7 @@ export class ChatsMessagesService implements Chats.IChatsMessagesService { @@ -41,6 +54,7 @@ export class ChatsMessagesService implements Chats.IChatsMessagesService {
.createQueryBuilder('it')
.leftJoinAndSelect('it.events', 'events')
.andWhere('it.chatId = :chatId', { chatId: params.chatId })
.andWhere('it.content IS NOT NULL')
.orderBy('it.createdAt', 'DESC')
await this.addSearchParams(query, params)
@ -112,6 +126,7 @@ export class ChatsMessagesService implements Chats.IChatsMessagesService { @@ -112,6 +126,7 @@ export class ChatsMessagesService implements Chats.IChatsMessagesService {
type: Chats.MessageType.Text,
userId: payload.userId,
salt: chat.salt,
uniqueKey: payload.uniqueKey,
})
await this.chatsAccessoryService.afterSendMessage(
@ -222,9 +237,19 @@ export class ChatsMessagesService implements Chats.IChatsMessagesService { @@ -222,9 +237,19 @@ export class ChatsMessagesService implements Chats.IChatsMessagesService {
chat.salt,
)
const extension = getFileExtension(fileUrl)
let realType = type
if (['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
realType = Chats.MessageType.Image
}
if (['mp4', 'mov', 'm4v'].includes(extension)) {
realType = Chats.MessageType.Video
}
console.log(extension, realType)
return {
content: encodedMessage,
type,
type: realType,
chatId: payload.chatId,
userId: payload.userId,
}
@ -319,11 +344,18 @@ export class ChatsMessagesService implements Chats.IChatsMessagesService { @@ -319,11 +344,18 @@ export class ChatsMessagesService implements Chats.IChatsMessagesService {
const message = await this.chatsMessagesRepository.findOne(messageId)
if (!message) throw new NotFoundException('Message not found')
await this.chatsMessagesDeletedRepository.save({
userId,
messageId,
chatId: message.chatId,
})
const chat = await this.chatsRepository.findOne({ id: message.chatId })
// Якщо персональний чат і користувач видаляє власне повідомлення то воно має бути видалена для всіх
if (chat.type === Chats.ChatType.Personal && message.userId === userId) {
await this.deleteChatMessage(userId, messageId)
} else {
await this.chatsMessagesDeletedRepository.save({
userId,
messageId,
chatId: message.chatId,
})
}
}
public async deleteChatMessage(userId: number, messageId: number) {
@ -386,6 +418,7 @@ export class ChatsMessagesService implements Chats.IChatsMessagesService { @@ -386,6 +418,7 @@ export class ChatsMessagesService implements Chats.IChatsMessagesService {
type: payload.type,
chatId: payload.chatId,
userId: payload.userId,
uniqueKey: payload.uniqueKey,
})
}
@ -562,3 +595,14 @@ export class ChatsMessagesService implements Chats.IChatsMessagesService { @@ -562,3 +595,14 @@ export class ChatsMessagesService implements Chats.IChatsMessagesService {
return message.content.fileUrl
}
}
function getFileExtension(url: string): string | null {
try {
const parts = url.split('.')
if (parts.length > 1) {
return parts.pop()?.split(/\#|\?/)[0] || null
}
return null
} catch (e) {
return null
}
}

72
src/domain/chats/services/chats-secret.service.ts

@ -0,0 +1,72 @@ @@ -0,0 +1,72 @@
import { Inject, Injectable } from '@nestjs/common'
import { SECRET_MOD_SERVICE } from 'src/domain/secret-mod/consts'
import { SecretMod } from 'src/domain/secret-mod/typing'
import { RedisService } from 'src/libs'
import { IChatsMembersRepository } from '../interfaces'
import { CHATS_MEMBERS_REPOSITORY } from '../consts'
@Injectable()
export class ChatsSecretService {
private REDIS_KEY = 'secret-chats'
constructor(
@Inject(SECRET_MOD_SERVICE)
private readonly secretModService: SecretMod.Service,
@Inject(CHATS_MEMBERS_REPOSITORY)
private readonly chatsMembersRepository: IChatsMembersRepository,
private readonly redisService: RedisService,
) {}
public async allocateSecretChats() {
const hiddenUsersIds = await this.secretModService.getHiddenUsersIds()
if (!hiddenUsersIds) {
await this.clear()
}
const result = await this.chatsMembersRepository
.createQueryBuilder('it')
.select('it.chatId', 'chatId')
.distinct(true)
.where('it.userId = ANY(:hiddenUsersIds)', { hiddenUsersIds })
.getRawMany()
const chatIds = result.map(it => it.chatId).sort((a, b) => a - b)
await this.saveToRedis(chatIds)
}
public async clear() {
await this.saveToRedis([])
}
public async getHiddenChatIds(requestedUserId?: number): Promise<number[]> {
if (!(await this.secretModService.isActive())) return []
const ids = await this.getFromRedis()
return ids
}
private async saveToRedis(ids: number[]) {
await this.redisService.set(this.REDIS_KEY, JSON.stringify(ids))
}
private async getFromRedis() {
try {
const data = await this.redisService.get(this.REDIS_KEY)
if (!data) return []
const result = JSON.parse(data)
return Array.isArray(result) ? result : []
} catch (e) {
return []
}
}
public async isChatHidden(chatId: number) {
const ids = await this.getHiddenChatIds()
return ids.includes(chatId)
}
}

46
src/domain/chats/services/chats.service.ts

@ -14,18 +14,32 @@ import { ChatsCryptService } from './chats-crypt.service' @@ -14,18 +14,32 @@ import { ChatsCryptService } from './chats-crypt.service'
import { transformFileUrl } from 'src/shared/transforms'
import { ChatsAccessoryService } from './chats-accessory.service'
import { Events } from 'src/core/enums'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { SECRET_MOD_SERVICE } from 'src/domain/secret-mod/consts'
import { SecretMod } from 'src/domain/secret-mod/typing'
import { ChatsSecretService } from './chats-secret.service'
@Injectable()
export class ChatsService implements Chats.IChatsService {
private readonly chatPreviewsPrefix = 'chat-previews'
constructor(
@Inject(USERS_SERVICE) private readonly usersService: Users.IUsersService,
@Inject(CHATS_REPOSITORY) private readonly chatsRepository: IChatsRepository,
@Inject(USERS_SERVICE)
private readonly usersService: Users.IUsersService,
@Inject(CHATS_REPOSITORY)
private readonly chatsRepository: IChatsRepository,
@Inject(FILES_STORAGE_SERVICE)
private readonly filesStorageService: FilesStorage.IFilesStorageService,
@Inject(SECRET_MOD_SERVICE)
private readonly secretModService: SecretMod.Service,
private readonly chatsCryptService: ChatsCryptService,
private readonly chatsAccessoryService: ChatsAccessoryService,
private readonly eventEmitter: EventEmitter2,
private readonly chatsSecretService: ChatsSecretService,
) {}
public async storeChat(userId: number, payload: Chats.ISaveChatPayload) {
@ -67,14 +81,22 @@ export class ChatsService implements Chats.IChatsService { @@ -67,14 +81,22 @@ export class ChatsService implements Chats.IChatsService {
usersIds,
})
await this.chatsAccessoryService.emitEventToChat(chat.id, 'chat/new-chat')
if (payload.type === Chats.ChatType.Group)
await this.chatsAccessoryService.pushNotificationToChatMembers(
Events.OnNewChatMember,
chat.id,
userId,
)
const isAnyUserHidden = await this.secretModService.isUsersHidden([
userId,
...payload.usersIds,
])
if (!isAnyUserHidden) {
await this.chatsAccessoryService.emitEventToChat(chat.id, 'chat/new-chat')
if (payload.type === Chats.ChatType.Group)
await this.chatsAccessoryService.pushNotificationToChatMembers(
Events.OnNewChatMember,
chat.id,
userId,
)
} else {
await this.chatsSecretService.allocateSecretChats()
}
return chat.id
}
@ -86,6 +108,8 @@ export class ChatsService implements Chats.IChatsService { @@ -86,6 +108,8 @@ export class ChatsService implements Chats.IChatsService {
userId,
payload.isChatMuted,
)
await this.eventEmitter.emit(Events.OnChatUpdated, { chatId: payload.chatId })
}
public async updateChat(chatId: number, userId: number, payload: Chats.IUpdateChatPayload) {
@ -114,6 +138,8 @@ export class ChatsService implements Chats.IChatsService { @@ -114,6 +138,8 @@ export class ChatsService implements Chats.IChatsService {
public async getChat(chatId: number, userId: number) {
const chat = await this.chatsRepository.findOne(chatId)
if (!chat) throw new NotFoundException('Chat not found')
const isHidden = await this.chatsSecretService.isChatHidden(chatId)
if (isHidden) throw new NotFoundException('Chat not found')
return await this.prepareChat(chat, userId)
}

5
src/domain/chats/services/index.ts

@ -7,6 +7,8 @@ import { ChatsService } from './chats.service' @@ -7,6 +7,8 @@ import { ChatsService } from './chats.service'
import { ChatsExportService } from './chats-export.service'
import { ChatsFixedService } from './chats-fixed.service'
import { ChatsSystemMessagesService } from './chats-system-messages.service'
import { ChatsCacheService } from './chats-cache.service'
import { ChatsForwardMessagesService } from './chats-forward-messages.service'
export const CHATS_SERVICES = [
ChatsService,
@ -18,6 +20,7 @@ export const CHATS_SERVICES = [ @@ -18,6 +20,7 @@ export const CHATS_SERVICES = [
ChatsExportService,
ChatsFixedService,
ChatsSystemMessagesService,
ChatsCacheService,
]
export {
@ -26,4 +29,6 @@ export { @@ -26,4 +29,6 @@ export {
ChatsMessagesService,
ChatsFixedService,
ChatsSystemMessagesService,
ChatsCacheService,
ChatsForwardMessagesService,
}

2
src/domain/comments/comments.module.ts

@ -5,6 +5,7 @@ import { provideClass } from 'src/shared' @@ -5,6 +5,7 @@ import { provideClass } from 'src/shared'
import { COMMENTS_REPOSITORY } from './consts'
import { Comment } from './entities'
import { CommentsService } from './services'
import { SecretModModule } from '../secret-mod/secret-mod.module'
@Module({})
export class CommentsModule {
@ -32,7 +33,6 @@ export class CommentsModule { @@ -32,7 +33,6 @@ export class CommentsModule {
return {
module: CommentsModule,
providers: CommentsModule.getProviders(),
// imports: PermissionsModule.getImports(),
exports: CommentsModule.getExports(),
}
}

21
src/domain/comments/services/comments.service.ts

@ -1,14 +1,20 @@ @@ -1,14 +1,20 @@
import { Inject, Injectable } from '@nestjs/common'
import { Comments } from 'src/core'
import { In } from 'typeorm'
import { In, Not } from 'typeorm'
import { COMMENTS_REPOSITORY } from '../consts'
import { ICommentsRepository } from '../interfaces/comments-db.interfaces'
import * as _ from 'lodash'
import { SecretMod } from 'src/domain/secret-mod/typing'
import { SECRET_MOD_SERVICE } from 'src/domain/secret-mod/consts'
@Injectable()
export class CommentsService implements Comments.ICommentsService {
constructor(
@Inject(COMMENTS_REPOSITORY) private readonly commentsRepository: ICommentsRepository,
@Inject(COMMENTS_REPOSITORY)
private readonly commentsRepository: ICommentsRepository,
@Inject(SECRET_MOD_SERVICE)
private readonly secretModService: SecretMod.Service,
) {}
public async save(payload: Comments.SaveCommentPayload) {
@ -34,8 +40,17 @@ export class CommentsService implements Comments.ICommentsService { @@ -34,8 +40,17 @@ export class CommentsService implements Comments.ICommentsService {
sort: 'ASC' | 'DESC' = 'DESC',
relations?: ['author'] | ['author', 'author.info'],
) {
const hiddenUsersIds = await this.secretModService.getHiddenUsersIds()
const query: any = {
id: In(ids),
}
if (!_.isEmpty(hiddenUsersIds)) {
query.authorId = Not(In(hiddenUsersIds))
}
return this.commentsRepository.find({
where: { id: In(ids) },
where: query,
order: { id: sort },
relations: relations,
})

49
src/domain/factories/tests/unit/factories-validator.service.spec.ts

@ -1,49 +0,0 @@ @@ -1,49 +0,0 @@
import { Test } from '@nestjs/testing'
import { RedisService } from 'src/libs'
import { NotFoundException } from 'src/shared'
import { mockRedisService } from 'src/shared/mocks'
import { FACTORIES_REPOSITORY } from '../../consts'
import { IFactoriesRepository } from '../../interfaces'
import { FactoriesValidatorService } from '../../services'
describe('Factories validator service', () => {
let service: FactoriesValidatorService
let repository: MockType<IFactoriesRepository>
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
FactoriesValidatorService,
{
provide: RedisService,
useValue: mockRedisService(),
},
{
provide: FACTORIES_REPOSITORY,
useValue: {
findOne: jest.fn(),
},
},
],
}).compile()
service = module.get(FactoriesValidatorService)
repository = module.get(FACTORIES_REPOSITORY)
})
it('should throw error if parent does not exist', async () => {
repository.findOne.mockReturnValueOnce(null)
expect(service.validateParentId(3)).rejects.toBeInstanceOf(NotFoundException)
})
it('should return void if parent exists', async () => {
repository.findOne.mockReturnValueOnce({})
expect(service.validateParentId(3)).resolves.toBeUndefined()
})
it('should return void if no parent id provided', async () => {
expect(service.validateParentId(null)).resolves.toBeUndefined()
})
})

77
src/domain/factories/tests/unit/factory-trees.service.spec.ts

@ -1,77 +0,0 @@ @@ -1,77 +0,0 @@
import { Test } from '@nestjs/testing'
import { RedisService } from 'src/libs'
import { NotFoundException } from 'src/shared'
import { mockRedisService } from 'src/shared/mocks'
import { FACTORIES_REPOSITORY } from '../../consts'
import { IFactoriesRepository } from '../../interfaces'
import { FactoryTreesService } from '../../services'
const factoryMock = {
id: 3,
authorId: 2,
name: 'Some factory',
shortName: 'sf',
path: ' | 6 | 4 | ',
}
describe('Factory threes service', () => {
let service: FactoryTreesService
let repository: MockType<IFactoriesRepository>
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
FactoryTreesService,
{
provide: RedisService,
useValue: mockRedisService(),
},
{
provide: FACTORIES_REPOSITORY,
useValue: {
findOne: jest.fn(),
find: jest.fn(),
},
},
],
}).compile()
service = module.get(FactoryTreesService)
repository = module.get(FACTORIES_REPOSITORY)
})
it('should generate three list path', async () => {
repository.findOne.mockReturnValueOnce(factoryMock)
expect(await service.generateThreeListPath(2)).toBe(' | 6 | 4 | 3 | ')
})
it('should throw an exception if no parent generating three list path', async () => {
repository.findOne.mockReturnValueOnce(null)
expect(service.generateThreeListPath(2)).rejects.toBeInstanceOf(NotFoundException)
})
it('should check if factory is ', async () => {
expect(service.isAncestor(2, factoryMock)).toBeFalsy()
})
it('should check if id belongs to the ancestor of entity ', async () => {
expect(service.isAncestor(2, factoryMock)).toBeFalsy()
expect(service.isAncestor(4, factoryMock)).toBeTruthy()
expect(service.isAncestor(6, factoryMock)).toBeTruthy()
})
it('should return three like list with children', async () => {
repository.find
.mockReturnValueOnce([factoryMock])
.mockReturnValueOnce([
{ ...factoryMock, id: 10 },
{ ...factoryMock, id: 12 },
])
.mockReturnValue([])
const res = await service.getTreeLikeList(1)
expect(res[0].children).toHaveLength(2)
})
})

1
src/domain/index.ts

@ -15,3 +15,4 @@ export * from './activities/activities.module' @@ -15,3 +15,4 @@ export * from './activities/activities.module'
export * from './chats/chats.module'
export * from './factories/factories.module'
export * from './versions/versions.module'
export * from './logger/logger.module'

18
src/domain/ips/entities/ip.entity.ts

@ -1,5 +1,14 @@ @@ -1,5 +1,14 @@
import { IPs } from 'src/core'
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'
import { User } from 'src/domain/users/entities'
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
@Entity('ips')
export class Ip implements IPs.IIP {
@ -20,4 +29,11 @@ export class Ip implements IPs.IIP { @@ -20,4 +29,11 @@ export class Ip implements IPs.IIP {
@Column({ nullable: true, type: 'char', default: IPs.IPListType.White })
listType: IPs.IPListType
@ManyToOne(() => User)
@JoinColumn({ name: 'userId' })
user?: User
@Column({ nullable: true })
blockReason?: string
}

2
src/domain/ips/ips.module.ts

@ -25,7 +25,7 @@ export class IPsModule { @@ -25,7 +25,7 @@ export class IPsModule {
}
static getExports() {
return [IPS_SERVICE]
return [IPS_SERVICE, IPS_REPOSITORY]
}
static forRoot(): DynamicModule {

1
src/domain/ips/services/ips-lists.service.ts

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { Inject, Injectable } from '@nestjs/common'
import { IPs } from 'src/core'
import { RedisService } from 'src/libs'

18
src/domain/ips/services/ips.service.ts

@ -7,11 +7,18 @@ import { IPS_REPOSITORY } from '../consts' @@ -7,11 +7,18 @@ import { IPS_REPOSITORY } from '../consts'
import { IIPsRepository } from '../interfaces'
import * as _ from 'lodash'
import { isEmpty } from 'lodash'
import { SECRET_MOD_SERVICE } from 'src/domain/secret-mod/consts'
import { SecretMod } from 'src/domain/secret-mod/typing'
@Injectable()
export class IPsService implements IPs.IIPsService {
constructor(
@Inject(IPS_REPOSITORY) private readonly ipsRepository: IIPsRepository,
@Inject(IPS_REPOSITORY)
private readonly ipsRepository: IIPsRepository,
@Inject(SECRET_MOD_SERVICE)
private readonly secretModService: SecretMod.Service,
private readonly ipsListsService: IPsListsService,
) {}
@ -32,8 +39,15 @@ export class IPsService implements IPs.IIPsService { @@ -32,8 +39,15 @@ export class IPsService implements IPs.IIPsService {
}
public async getList(pagination: IPagination, params: IPs.IFetchIpsListParams) {
const query = this.ipsRepository.createQueryBuilder('it')
const hiddenUsersIds = await this.secretModService.getHiddenUsersIds()
const query = this.ipsRepository
.createQueryBuilder('it')
.leftJoinAndSelect('it.user', 'user')
if (!_.isEmpty(hiddenUsersIds)) {
query.andWhere('it.userId NOT IN (:...ids)', { ids: hiddenUsersIds })
}
if (params.type) query.andWhere('it.listType = :type', { type: params?.type })
if (params.ip) query.andWhere('it.ip ILIKE :ip', { ip: `%${params?.ip}%` })

1
src/domain/logger/filter/index.ts

@ -0,0 +1 @@ @@ -0,0 +1 @@
export * from './logger-exception.filter'

43
src/domain/logger/filter/logger-exception.filter.ts

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'
import { HttpAdapterHost } from '@nestjs/core'
import { LoggerService } from '../services'
@Catch()
export class LoggerExceptionsFilter implements ExceptionFilter {
constructor(
private readonly httpAdapterHost: HttpAdapterHost,
private readonly loggerService: LoggerService,
) {}
catch(exception: unknown, host: ArgumentsHost): void {
const { httpAdapter } = this.httpAdapterHost
if (!httpAdapter) {
throw new Error('HttpAdapter is not available.')
}
const ctx = host.switchToHttp()
const httpStatus =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR
if (
httpStatus >= HttpStatus.BAD_REQUEST &&
httpStatus <= HttpStatus.HTTP_VERSION_NOT_SUPPORTED
) {
this.loggerService.logException(exception)
}
const responseBody = {
statusCode: httpStatus,
timestamp: new Date().toISOString(),
path: httpAdapter.getRequestUrl(ctx.getRequest()),
}
httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus)
throw exception
}
}

3
src/domain/logger/index.ts

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
export * from './services'
export * from './typing'
export * from './filter'

9
src/domain/logger/logger.module.ts

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common'
import { LoggerService } from './services'
@Global()
@Module({
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}

1
src/domain/logger/services/index.ts

@ -0,0 +1 @@ @@ -0,0 +1 @@
export * from './logger.service'

46
src/domain/logger/services/logger.service.ts

@ -0,0 +1,46 @@ @@ -0,0 +1,46 @@
import * as Sentry from '@sentry/node'
import { LoggerMessage } from '../typing/enums'
import { getEnv } from 'src/shared'
export class LoggerService {
constructor() {
this.init()
}
private init(): void {
Sentry.init({
dsn: getEnv('SENTRY_DNS'),
environment: getEnv('SENTRY_ENVIROMENT'),
integrations: [],
tracesSampleRate: 1.0,
profilesSampleRate: 1.0,
})
}
public logEvent(
message: LoggerMessage,
user?: { id: string },
extra?: any,
tags?: Record<string, string>,
): void {
try {
Sentry.captureEvent({
message,
user,
extra,
level: 'log',
tags,
})
} catch (error) {
console.error('Error logging event:', error)
}
}
public logException(error: any, extra?: any, tags?: Record<string, string>): void {
try {
Sentry.captureException(error, { extra, tags })
} catch (error) {
console.error('Error logging exception:', error)
}
}
}

1
src/domain/logger/typing/enums/index.ts

@ -0,0 +1 @@ @@ -0,0 +1 @@
export * from './logger-messages.enum'

15
src/domain/logger/typing/enums/logger-messages.enum.ts

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
export enum LoggerMessage {
UserLoggedOut = 'user logged out',
UserLoggedIn = 'user logged in',
UserStatusChanged = 'user status changed',
RefReshToken = 'refresh token',
FinishSession = 'finish session',
FinishSessionByToken = 'finish session by token',
FinishAllUserSessions = 'finish all user sessions',
}

1
src/domain/logger/typing/index.ts

@ -0,0 +1 @@ @@ -0,0 +1 @@
export * from './enums'

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save