├── .dockerignore ├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── checkCodeCompilationForPeter.yaml │ └── trigger.jenkins.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── babel.config.js ├── compose.prod.yaml ├── compose.yaml ├── coverage ├── clover.xml ├── coverage-final.json ├── lcov-report │ ├── base.css │ ├── block-navigation.js │ ├── favicon.png │ ├── index.html │ ├── prettify.css │ ├── prettify.js │ ├── sort-arrow-sprite.png │ └── sorter.js └── lcov.info ├── dev.Dockerfile ├── docs ├── ER-Diagram.svg ├── PROJECT_FLOW.md ├── System-Design.svg ├── api │ ├── .gitkeep │ ├── auth.swagger.ts │ ├── chat.swagger.ts │ ├── message.swagger.ts │ ├── oauth.swagger.ts │ ├── privacy.swagger.ts │ ├── search.swagger.ts │ ├── sockets.swagger.ts │ ├── story.swagger.ts │ └── user.swagger.ts └── functions │ ├── .nojekyll │ ├── assets │ ├── highlight.css │ ├── icons.js │ ├── icons.svg │ ├── main.js │ ├── navigation.js │ ├── search.js │ └── style.css │ ├── index.html │ └── modules.html ├── jest.config.js ├── migrate-mongo-config.js ├── package-lock.json ├── package.json ├── prod.Dockerfile ├── run.sh ├── src ├── app.ts ├── config │ ├── allowedOrigins.json │ ├── cors.ts │ ├── env.ts │ ├── fileUploads.ts │ ├── firebase.ts │ ├── mongoDB.ts │ ├── passport.ts │ ├── redis.ts │ └── session.ts ├── controllers │ ├── authController.ts │ ├── chatController.ts │ ├── handlerFactory.ts │ ├── privacyController.ts │ ├── searchController.ts │ ├── storyController.ts │ └── userController.ts ├── database │ ├── migrations │ │ └── 20241021134336-init-database.js │ └── seed │ │ ├── json │ │ └── users.json │ │ ├── seed.ts │ │ └── userSeed.ts ├── errors │ ├── AppError.ts │ ├── errorHandlers.ts │ ├── globalErrorHandler.ts │ ├── uncaughtExceptionHandler.ts │ └── unhandledRejectionHandler.ts ├── index.ts ├── middlewares │ ├── authMiddleware.ts │ └── chatMiddlewares.ts ├── models │ ├── chatModel.ts │ ├── communicationModel.ts │ ├── groupChannelModel.ts │ ├── inviteModel.ts │ ├── messageModel.ts │ ├── normalChatModel.ts │ ├── storyModel.ts │ ├── userModel.ts │ └── voiceCallModel.ts ├── public │ └── media │ │ └── .gitkeep ├── routes │ ├── apiRoute.ts │ ├── authRoute.ts │ ├── chatRoute.ts │ ├── oauthRoute.ts │ ├── privacyRoute.ts │ ├── searchRoute.ts │ ├── storyRoute.ts │ └── userRoute.ts ├── server.ts ├── services │ ├── authService.ts │ ├── chatService.ts │ ├── googleAIService.ts │ ├── sessionService.ts │ ├── storyService.ts │ └── userService.ts ├── sockets │ ├── MessagingServices.ts │ ├── chats.ts │ ├── messages.ts │ ├── middlewares.ts │ ├── notifications.ts │ ├── socket.ts │ ├── voiceCalls.ts │ └── voiceCallsServices.ts ├── tests │ └── auth.test.ts ├── types │ ├── chat.ts │ ├── communication.ts │ ├── groupChannel.ts │ ├── invite.ts │ ├── message.ts │ ├── normalChat.ts │ ├── recaptchaResponse.ts │ ├── story.ts │ ├── user.ts │ └── voiceCall.ts └── utils │ ├── catchAsync.ts │ ├── deleteFile.ts │ ├── email.ts │ ├── emailMessages.ts │ ├── encryption.ts │ ├── generateConfirmationCode.ts │ └── static-analysis-script.mjs ├── static-analysis-report.json ├── static.cloc.json └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | node_modules -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development-production 2 | ENV=docker-localhost 3 | PORT=3000 4 | MONGO_DB_DOCKER_URL=mongodb://{USER}:{PASSWORD}@mongo:27017/telwareDB?authSource=admin 5 | MONGO_DB_LOCALHOST_URL=mongodb://localhost:27017/telwareDB 6 | MONGO_DB_USER=ROOT 7 | MONGO_DB_PASSWORD=1234 8 | REDIS_DOCKER_URL=redis://redis:6379 9 | REDIS_LOCALHOST_URL=redis://localhost:6379 10 | 11 | SERVER_URL=http://localhost:3000 12 | WEBSOCKET_URL=ws://localhost:3000 13 | 14 | ENCRYPTION_KEY_SECRET=encryption-key-secret #(32 bytes) 15 | ENCRYPTION_KEY_IV=encryption-key-iv #(12 bytes) 16 | 17 | SESSION_SECRET=session-secret 18 | SESSION_EXPIRES_IN=180d 19 | 20 | RECAPTCHA_SECRET=recaptcha-secret 21 | RECAPTCHA_SITE=recaptcha-site 22 | DISABLE_RECAPTCHA=false 23 | 24 | EMAIL_PROVIDER=mailtrap-gmail 25 | 26 | TELWARE_EMAIL=telware@gmail.com 27 | TELWARE_PASSWORD=telware-password 28 | GMAIL_HOST=smtp.gmail.com 29 | 30 | MAILTRAP_USERNAME=mailtrap-username 31 | MAILTRAP_PASSWORD=mailtrap-password 32 | MAILTRAP_HOST=smtp.mailtrap.io 33 | MAIL_PORT=587 34 | 35 | VERIFICATION_CODE_EXPIRES_IN=10 #minutes 36 | RESET_TOKEN_EXPIRES_IN=10 #minutes 37 | 38 | GOOGLE_CLIENT_ID=google-client-id 39 | GOOGLE_CLIENT_SECRET=google-client-secret 40 | 41 | GITHUB_CLIENT_ID=github-client-id 42 | GITHUB_CLIENT_SECRET=githu-client-secret 43 | 44 | CROSS_PLATFORM_OAUTH_REDIRECT_URL=telware://telware.online/social-auth-loading 45 | FRONTEND_URL=localhost:5174 46 | 47 | GROUP_SIZE= 5 48 | 49 | FIREBASE_SERVICE_ACCOUNT='{ 50 | "type": "service_account", 51 | "project_id": "your-project-id", 52 | "private_key_id": "your-private-key-id", 53 | "private_key": "-----BEGIN PRIVATE KEY-----\\nYOUR_PRIVATE_KEY\\n-----END PRIVATE KEY-----\\n", 54 | "client_email": "your-client-email@your-project.iam.gserviceaccount.com", 55 | "client_id": "your-client-id", 56 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 57 | "token_uri": "https://oauth2.googleapis.com/token", 58 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 59 | "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/your-client-email%40your-project.iam.gserviceaccount.com" 60 | "universe_domain": "googleapis.com" 61 | }' 62 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true, 4 | "browser": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "project": "./tsconfig.json", 11 | "ecmaVersion": 2021, 12 | "sourceType": "module" 13 | }, 14 | "extends": ["airbnb", "prettier", "plugin:node/recommended"], 15 | "plugins": ["node", "jest", "prettier"], 16 | "rules": { 17 | "prettier/prettier": "off", 18 | "spaced-comment": "off", 19 | "no-console": "warn", 20 | "consistent-return": "off", 21 | "func-names": "off", 22 | "no-process-exit": "off", 23 | "no-param-reassign": "off", 24 | "no-underscore-dangle": "off", 25 | "import/extensions": "off", 26 | "class-methods-use-this": "off", 27 | "lines-between-class-members": "off", 28 | "prefer-destructuring": ["error", { "object": true, "array": false }], 29 | "no-unused-vars": [ 30 | "error", 31 | { 32 | "argsIgnorePattern": "req|res|next|^_", 33 | "varsIgnorePattern": "req|res|next|^_" 34 | } 35 | ], 36 | "node/no-missing-import": "off", 37 | "node/no-unsupported-features/es-syntax": [ 38 | "error", 39 | { 40 | "version": ">=13.0.0", 41 | "ignores": ["modules"] 42 | } 43 | ] 44 | }, 45 | "settings": { 46 | "import/resolver": { 47 | "typescript": { 48 | "project": "./tsconfig.json" 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/checkCodeCompilationForPeter.yaml: -------------------------------------------------------------------------------- 1 | name: Check That Code Compiles For Peter 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | - main 8 | 9 | pull_request: 10 | branches: 11 | - main 12 | - develop 13 | 14 | pull_request_target: 15 | branches: 16 | - main 17 | - develop 18 | 19 | jobs: 20 | check-code-compilation: 21 | 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Check out code 26 | uses: actions/checkout@v2 27 | 28 | - name: setup node 29 | uses: actions/setup-node@v2 30 | with: 31 | node-version: 'lts/*' 32 | 33 | - name: Install dependencies 34 | run: npm install 35 | 36 | - name: Run Server 37 | run: npm run build 38 | 39 | 40 | -------------------------------------------------------------------------------- /.github/workflows/trigger.jenkins.yaml: -------------------------------------------------------------------------------- 1 | name: Trigger Jenkins Job 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - develop 8 | pull_request: 9 | types: [closed] 10 | branches: 11 | - main 12 | - develop 13 | 14 | jobs: 15 | trigger-jenkins: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Set Jenkins job name based on branch 20 | id: jenkins_job_name 21 | run: | 22 | if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then 23 | echo "job_name=backend" >> $GITHUB_ENV 24 | elif [[ "${{ github.ref }}" == "refs/heads/develop" ]]; then 25 | echo "job_name=backend_dev" >> $GITHUB_ENV 26 | else 27 | echo "No Jenkins job to trigger for this branch." 28 | exit 0 29 | fi 30 | 31 | - name: Trigger Jenkins job 32 | if: env.job_name != '' 33 | env: 34 | JENKINS_URL: ${{vars.JENKINS_URL}} 35 | JENKINS_TOKEN: ${{ secrets.JENKINS_TOKEN }} 36 | JOB_NAME: ${{ env.job_name }} 37 | run: | 38 | echo "Triggering Jenkins job $JOB_NAME" 39 | curl -X POST "$JENKINS_URL/buildByToken/build?job=$JOB_NAME&token=$JENKINS_TOKEN" 40 | 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | **/dist 3 | ./src/public/ 4 | .git 5 | npm-debug.log 6 | .coverage 7 | .coverage.* 8 | .env 9 | .aws 10 | OAuth 11 | public -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "es5", 7 | "bracketSpacing": true 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # telware backend 2 | 3 | 4 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=TelwareSW_telware-backend&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=TelwareSW_telware-backend) 5 | [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=TelwareSW_telware-backend&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=TelwareSW_telware-backend) 6 | [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=TelwareSW_telware-backend&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=TelwareSW_telware-backend) 7 | [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=TelwareSW_telware-backend&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=TelwareSW_telware-backend) 8 | [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=TelwareSW_telware-backend&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=TelwareSW_telware-backend) 9 | [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=TelwareSW_telware-backend&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=TelwareSW_telware-backend) 10 | 11 | 12 | Backend Repo for TelWare Messaging Platform 13 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /compose.prod.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | mongo: 3 | image: mongo 4 | restart: unless-stopped 5 | environment: 6 | MONGO_INITDB_ROOT_USERNAME: ${MONGO_DB_USER:-ROOT} 7 | MONGO_INITDB_ROOT_PASSWORD: ${MONGO_DB_PASSWORD:-1234} 8 | networks: 9 | - backend-net-prod 10 | healthcheck: 11 | test: ['CMD', 'mongosh', '--quiet', '--eval', "db.adminCommand('ping')"] 12 | interval: 20m 13 | timeout: 5s 14 | retries: 5 15 | start_period: 45s 16 | volumes: 17 | - mongo-data:/data/db 18 | 19 | 20 | backend: 21 | image: telware/backend-prod 22 | build: 23 | context: . 24 | dockerfile: prod.Dockerfile 25 | ports: 26 | - '${PORT:-3000}:3000' 27 | volumes: 28 | - /app/node_modules 29 | restart: unless-stopped 30 | depends_on: 31 | mongo: 32 | condition: service_healthy 33 | redis: 34 | condition: service_healthy 35 | networks: 36 | - backend-net-prod 37 | 38 | redis: 39 | image: redis/redis-stack 40 | restart: unless-stopped 41 | networks: 42 | - backend-net-prod 43 | healthcheck: 44 | test: ["CMD", "redis-cli", "ping"] 45 | interval: 20m 46 | timeout: 5s 47 | retries: 5 48 | start_period: 45s 49 | 50 | networks: 51 | backend-net-prod: 52 | 53 | volumes: 54 | mongo-data: 55 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | mongo: 3 | image: mongo 4 | restart: unless-stopped 5 | environment: 6 | MONGO_INITDB_ROOT_USERNAME: ${MONGO_DB_USER:-ROOT} 7 | MONGO_INITDB_ROOT_PASSWORD: ${MONGO_DB_PASSWORD:-1234} 8 | networks: 9 | - backend-net 10 | healthcheck: 11 | test: ['CMD', 'mongosh', '--quiet', '--eval', "db.adminCommand('ping')"] 12 | interval: 20m 13 | timeout: 5s 14 | retries: 5 15 | start_period: 45s 16 | volumes: 17 | - mongo-data:/data/db 18 | # Uncomment Only If You Want To access the mongodb instance from your machine 19 | # ports: 20 | # - 27017:27017 21 | 22 | mongo-express: 23 | image: mongo-express 24 | restart: unless-stopped 25 | ports: 26 | - 8081:8081 27 | environment: 28 | ME_CONFIG_MONGODB_ADMINUSERNAME: ${MONGO_DB_USER} 29 | ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGO_DB_PASSWORD} 30 | ME_CONFIG_MONGODB_URL: mongodb://${MONGO_DB_USER}:${MONGO_DB_PASSWORD}@mongo:27017/telwareDB?authSource=admin 31 | ME_CONFIG_BASICAUTH: 'false' 32 | networks: 33 | - backend-net 34 | depends_on: 35 | mongo: 36 | condition: service_healthy 37 | 38 | backend: 39 | image: telware/backend 40 | build: 41 | context: . 42 | dockerfile: dev.Dockerfile 43 | ports: 44 | - '${PORT:-3000}:3000' 45 | volumes: 46 | - .:/app 47 | - /app/node_modules 48 | environment: 49 | - NODE_ENV=${NODE_ENV:-development} 50 | - PORT=${PORT:-3000} 51 | restart: unless-stopped 52 | depends_on: 53 | mongo: 54 | condition: service_healthy 55 | redis: 56 | condition: service_healthy 57 | networks: 58 | - backend-net 59 | 60 | redis: 61 | image: redis/redis-stack 62 | restart: unless-stopped 63 | ports: 64 | - 6379:6379 65 | - 8001:8001 66 | networks: 67 | - backend-net 68 | healthcheck: 69 | test: ["CMD", "redis-cli", "ping"] 70 | interval: 20m 71 | timeout: 5s 72 | retries: 5 73 | start_period: 45s 74 | # TODO: Add some environment variables that might be used in production 75 | 76 | networks: 77 | backend-net: 78 | 79 | volumes: 80 | mongo-data: 81 | -------------------------------------------------------------------------------- /coverage/clover.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /coverage/coverage-final.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /coverage/lcov-report/base.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | margin:0; padding: 0; 3 | height: 100%; 4 | } 5 | body { 6 | font-family: Helvetica Neue, Helvetica, Arial; 7 | font-size: 14px; 8 | color:#333; 9 | } 10 | .small { font-size: 12px; } 11 | *, *:after, *:before { 12 | -webkit-box-sizing:border-box; 13 | -moz-box-sizing:border-box; 14 | box-sizing:border-box; 15 | } 16 | h1 { font-size: 20px; margin: 0;} 17 | h2 { font-size: 14px; } 18 | pre { 19 | font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; 20 | margin: 0; 21 | padding: 0; 22 | -moz-tab-size: 2; 23 | -o-tab-size: 2; 24 | tab-size: 2; 25 | } 26 | a { color:#0074D9; text-decoration:none; } 27 | a:hover { text-decoration:underline; } 28 | .strong { font-weight: bold; } 29 | .space-top1 { padding: 10px 0 0 0; } 30 | .pad2y { padding: 20px 0; } 31 | .pad1y { padding: 10px 0; } 32 | .pad2x { padding: 0 20px; } 33 | .pad2 { padding: 20px; } 34 | .pad1 { padding: 10px; } 35 | .space-left2 { padding-left:55px; } 36 | .space-right2 { padding-right:20px; } 37 | .center { text-align:center; } 38 | .clearfix { display:block; } 39 | .clearfix:after { 40 | content:''; 41 | display:block; 42 | height:0; 43 | clear:both; 44 | visibility:hidden; 45 | } 46 | .fl { float: left; } 47 | @media only screen and (max-width:640px) { 48 | .col3 { width:100%; max-width:100%; } 49 | .hide-mobile { display:none!important; } 50 | } 51 | 52 | .quiet { 53 | color: #7f7f7f; 54 | color: rgba(0,0,0,0.5); 55 | } 56 | .quiet a { opacity: 0.7; } 57 | 58 | .fraction { 59 | font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; 60 | font-size: 10px; 61 | color: #555; 62 | background: #E8E8E8; 63 | padding: 4px 5px; 64 | border-radius: 3px; 65 | vertical-align: middle; 66 | } 67 | 68 | div.path a:link, div.path a:visited { color: #333; } 69 | table.coverage { 70 | border-collapse: collapse; 71 | margin: 10px 0 0 0; 72 | padding: 0; 73 | } 74 | 75 | table.coverage td { 76 | margin: 0; 77 | padding: 0; 78 | vertical-align: top; 79 | } 80 | table.coverage td.line-count { 81 | text-align: right; 82 | padding: 0 5px 0 20px; 83 | } 84 | table.coverage td.line-coverage { 85 | text-align: right; 86 | padding-right: 10px; 87 | min-width:20px; 88 | } 89 | 90 | table.coverage td span.cline-any { 91 | display: inline-block; 92 | padding: 0 5px; 93 | width: 100%; 94 | } 95 | .missing-if-branch { 96 | display: inline-block; 97 | margin-right: 5px; 98 | border-radius: 3px; 99 | position: relative; 100 | padding: 0 4px; 101 | background: #333; 102 | color: yellow; 103 | } 104 | 105 | .skip-if-branch { 106 | display: none; 107 | margin-right: 10px; 108 | position: relative; 109 | padding: 0 4px; 110 | background: #ccc; 111 | color: white; 112 | } 113 | .missing-if-branch .typ, .skip-if-branch .typ { 114 | color: inherit !important; 115 | } 116 | .coverage-summary { 117 | border-collapse: collapse; 118 | width: 100%; 119 | } 120 | .coverage-summary tr { border-bottom: 1px solid #bbb; } 121 | .keyline-all { border: 1px solid #ddd; } 122 | .coverage-summary td, .coverage-summary th { padding: 10px; } 123 | .coverage-summary tbody { border: 1px solid #bbb; } 124 | .coverage-summary td { border-right: 1px solid #bbb; } 125 | .coverage-summary td:last-child { border-right: none; } 126 | .coverage-summary th { 127 | text-align: left; 128 | font-weight: normal; 129 | white-space: nowrap; 130 | } 131 | .coverage-summary th.file { border-right: none !important; } 132 | .coverage-summary th.pct { } 133 | .coverage-summary th.pic, 134 | .coverage-summary th.abs, 135 | .coverage-summary td.pct, 136 | .coverage-summary td.abs { text-align: right; } 137 | .coverage-summary td.file { white-space: nowrap; } 138 | .coverage-summary td.pic { min-width: 120px !important; } 139 | .coverage-summary tfoot td { } 140 | 141 | .coverage-summary .sorter { 142 | height: 10px; 143 | width: 7px; 144 | display: inline-block; 145 | margin-left: 0.5em; 146 | background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; 147 | } 148 | .coverage-summary .sorted .sorter { 149 | background-position: 0 -20px; 150 | } 151 | .coverage-summary .sorted-desc .sorter { 152 | background-position: 0 -10px; 153 | } 154 | .status-line { height: 10px; } 155 | /* yellow */ 156 | .cbranch-no { background: yellow !important; color: #111; } 157 | /* dark red */ 158 | .red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } 159 | .low .chart { border:1px solid #C21F39 } 160 | .highlighted, 161 | .highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ 162 | background: #C21F39 !important; 163 | } 164 | /* medium red */ 165 | .cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } 166 | /* light red */ 167 | .low, .cline-no { background:#FCE1E5 } 168 | /* light green */ 169 | .high, .cline-yes { background:rgb(230,245,208) } 170 | /* medium green */ 171 | .cstat-yes { background:rgb(161,215,106) } 172 | /* dark green */ 173 | .status-line.high, .high .cover-fill { background:rgb(77,146,33) } 174 | .high .chart { border:1px solid rgb(77,146,33) } 175 | /* dark yellow (gold) */ 176 | .status-line.medium, .medium .cover-fill { background: #f9cd0b; } 177 | .medium .chart { border:1px solid #f9cd0b; } 178 | /* light yellow */ 179 | .medium { background: #fff4c2; } 180 | 181 | .cstat-skip { background: #ddd; color: #111; } 182 | .fstat-skip { background: #ddd; color: #111 !important; } 183 | .cbranch-skip { background: #ddd !important; color: #111; } 184 | 185 | span.cline-neutral { background: #eaeaea; } 186 | 187 | .coverage-summary td.empty { 188 | opacity: .5; 189 | padding-top: 4px; 190 | padding-bottom: 4px; 191 | line-height: 1; 192 | color: #888; 193 | } 194 | 195 | .cover-fill, .cover-empty { 196 | display:inline-block; 197 | height: 12px; 198 | } 199 | .chart { 200 | line-height: 0; 201 | } 202 | .cover-empty { 203 | background: white; 204 | } 205 | .cover-full { 206 | border-right: none !important; 207 | } 208 | pre.prettyprint { 209 | border: none !important; 210 | padding: 0 !important; 211 | margin: 0 !important; 212 | } 213 | .com { color: #999 !important; } 214 | .ignore-none { color: #999; font-weight: normal; } 215 | 216 | .wrapper { 217 | min-height: 100%; 218 | height: auto !important; 219 | height: 100%; 220 | margin: 0 auto -48px; 221 | } 222 | .footer, .push { 223 | height: 48px; 224 | } 225 | -------------------------------------------------------------------------------- /coverage/lcov-report/block-navigation.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var jumpToCode = (function init() { 3 | // Classes of code we would like to highlight in the file view 4 | var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; 5 | 6 | // Elements to highlight in the file listing view 7 | var fileListingElements = ['td.pct.low']; 8 | 9 | // We don't want to select elements that are direct descendants of another match 10 | var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` 11 | 12 | // Selecter that finds elements on the page to which we can jump 13 | var selector = 14 | fileListingElements.join(', ') + 15 | ', ' + 16 | notSelector + 17 | missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` 18 | 19 | // The NodeList of matching elements 20 | var missingCoverageElements = document.querySelectorAll(selector); 21 | 22 | var currentIndex; 23 | 24 | function toggleClass(index) { 25 | missingCoverageElements 26 | .item(currentIndex) 27 | .classList.remove('highlighted'); 28 | missingCoverageElements.item(index).classList.add('highlighted'); 29 | } 30 | 31 | function makeCurrent(index) { 32 | toggleClass(index); 33 | currentIndex = index; 34 | missingCoverageElements.item(index).scrollIntoView({ 35 | behavior: 'smooth', 36 | block: 'center', 37 | inline: 'center' 38 | }); 39 | } 40 | 41 | function goToPrevious() { 42 | var nextIndex = 0; 43 | if (typeof currentIndex !== 'number' || currentIndex === 0) { 44 | nextIndex = missingCoverageElements.length - 1; 45 | } else if (missingCoverageElements.length > 1) { 46 | nextIndex = currentIndex - 1; 47 | } 48 | 49 | makeCurrent(nextIndex); 50 | } 51 | 52 | function goToNext() { 53 | var nextIndex = 0; 54 | 55 | if ( 56 | typeof currentIndex === 'number' && 57 | currentIndex < missingCoverageElements.length - 1 58 | ) { 59 | nextIndex = currentIndex + 1; 60 | } 61 | 62 | makeCurrent(nextIndex); 63 | } 64 | 65 | return function jump(event) { 66 | if ( 67 | document.getElementById('fileSearch') === document.activeElement && 68 | document.activeElement != null 69 | ) { 70 | // if we're currently focused on the search input, we don't want to navigate 71 | return; 72 | } 73 | 74 | switch (event.which) { 75 | case 78: // n 76 | case 74: // j 77 | goToNext(); 78 | break; 79 | case 66: // b 80 | case 75: // k 81 | case 80: // p 82 | goToPrevious(); 83 | break; 84 | } 85 | }; 86 | })(); 87 | window.addEventListener('keydown', jumpToCode); 88 | -------------------------------------------------------------------------------- /coverage/lcov-report/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TelwareSW/telware-backend/4bc3129f1e28bbfc02b57516d8a47a3ec4550257/coverage/lcov-report/favicon.png -------------------------------------------------------------------------------- /coverage/lcov-report/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Code coverage report for All files 7 | 8 | 9 | 10 | 11 | 12 | 17 | 18 | 19 | 20 |
21 |
22 |

All files

23 |
24 | 25 |
26 | Unknown% 27 | Statements 28 | 0/0 29 |
30 | 31 | 32 |
33 | Unknown% 34 | Branches 35 | 0/0 36 |
37 | 38 | 39 |
40 | Unknown% 41 | Functions 42 | 0/0 43 |
44 | 45 | 46 |
47 | Unknown% 48 | Lines 49 | 0/0 50 |
51 | 52 | 53 |
54 |

55 | Press n or j to go to the next uncovered block, b, p or k for the previous block. 56 |

57 | 63 |
64 |
65 |
66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 |
FileStatementsBranchesFunctionsLines
83 |
84 |
85 |
86 | 91 | 92 | 97 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /coverage/lcov-report/prettify.css: -------------------------------------------------------------------------------- 1 | .pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} 2 | -------------------------------------------------------------------------------- /coverage/lcov-report/sort-arrow-sprite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TelwareSW/telware-backend/4bc3129f1e28bbfc02b57516d8a47a3ec4550257/coverage/lcov-report/sort-arrow-sprite.png -------------------------------------------------------------------------------- /coverage/lcov-report/sorter.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var addSorting = (function() { 3 | 'use strict'; 4 | var cols, 5 | currentSort = { 6 | index: 0, 7 | desc: false 8 | }; 9 | 10 | // returns the summary table element 11 | function getTable() { 12 | return document.querySelector('.coverage-summary'); 13 | } 14 | // returns the thead element of the summary table 15 | function getTableHeader() { 16 | return getTable().querySelector('thead tr'); 17 | } 18 | // returns the tbody element of the summary table 19 | function getTableBody() { 20 | return getTable().querySelector('tbody'); 21 | } 22 | // returns the th element for nth column 23 | function getNthColumn(n) { 24 | return getTableHeader().querySelectorAll('th')[n]; 25 | } 26 | 27 | function onFilterInput() { 28 | const searchValue = document.getElementById('fileSearch').value; 29 | const rows = document.getElementsByTagName('tbody')[0].children; 30 | for (let i = 0; i < rows.length; i++) { 31 | const row = rows[i]; 32 | if ( 33 | row.textContent 34 | .toLowerCase() 35 | .includes(searchValue.toLowerCase()) 36 | ) { 37 | row.style.display = ''; 38 | } else { 39 | row.style.display = 'none'; 40 | } 41 | } 42 | } 43 | 44 | // loads the search box 45 | function addSearchBox() { 46 | var template = document.getElementById('filterTemplate'); 47 | var templateClone = template.content.cloneNode(true); 48 | templateClone.getElementById('fileSearch').oninput = onFilterInput; 49 | template.parentElement.appendChild(templateClone); 50 | } 51 | 52 | // loads all columns 53 | function loadColumns() { 54 | var colNodes = getTableHeader().querySelectorAll('th'), 55 | colNode, 56 | cols = [], 57 | col, 58 | i; 59 | 60 | for (i = 0; i < colNodes.length; i += 1) { 61 | colNode = colNodes[i]; 62 | col = { 63 | key: colNode.getAttribute('data-col'), 64 | sortable: !colNode.getAttribute('data-nosort'), 65 | type: colNode.getAttribute('data-type') || 'string' 66 | }; 67 | cols.push(col); 68 | if (col.sortable) { 69 | col.defaultDescSort = col.type === 'number'; 70 | colNode.innerHTML = 71 | colNode.innerHTML + ''; 72 | } 73 | } 74 | return cols; 75 | } 76 | // attaches a data attribute to every tr element with an object 77 | // of data values keyed by column name 78 | function loadRowData(tableRow) { 79 | var tableCols = tableRow.querySelectorAll('td'), 80 | colNode, 81 | col, 82 | data = {}, 83 | i, 84 | val; 85 | for (i = 0; i < tableCols.length; i += 1) { 86 | colNode = tableCols[i]; 87 | col = cols[i]; 88 | val = colNode.getAttribute('data-value'); 89 | if (col.type === 'number') { 90 | val = Number(val); 91 | } 92 | data[col.key] = val; 93 | } 94 | return data; 95 | } 96 | // loads all row data 97 | function loadData() { 98 | var rows = getTableBody().querySelectorAll('tr'), 99 | i; 100 | 101 | for (i = 0; i < rows.length; i += 1) { 102 | rows[i].data = loadRowData(rows[i]); 103 | } 104 | } 105 | // sorts the table using the data for the ith column 106 | function sortByIndex(index, desc) { 107 | var key = cols[index].key, 108 | sorter = function(a, b) { 109 | a = a.data[key]; 110 | b = b.data[key]; 111 | return a < b ? -1 : a > b ? 1 : 0; 112 | }, 113 | finalSorter = sorter, 114 | tableBody = document.querySelector('.coverage-summary tbody'), 115 | rowNodes = tableBody.querySelectorAll('tr'), 116 | rows = [], 117 | i; 118 | 119 | if (desc) { 120 | finalSorter = function(a, b) { 121 | return -1 * sorter(a, b); 122 | }; 123 | } 124 | 125 | for (i = 0; i < rowNodes.length; i += 1) { 126 | rows.push(rowNodes[i]); 127 | tableBody.removeChild(rowNodes[i]); 128 | } 129 | 130 | rows.sort(finalSorter); 131 | 132 | for (i = 0; i < rows.length; i += 1) { 133 | tableBody.appendChild(rows[i]); 134 | } 135 | } 136 | // removes sort indicators for current column being sorted 137 | function removeSortIndicators() { 138 | var col = getNthColumn(currentSort.index), 139 | cls = col.className; 140 | 141 | cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); 142 | col.className = cls; 143 | } 144 | // adds sort indicators for current column being sorted 145 | function addSortIndicators() { 146 | getNthColumn(currentSort.index).className += currentSort.desc 147 | ? ' sorted-desc' 148 | : ' sorted'; 149 | } 150 | // adds event listeners for all sorter widgets 151 | function enableUI() { 152 | var i, 153 | el, 154 | ithSorter = function ithSorter(i) { 155 | var col = cols[i]; 156 | 157 | return function() { 158 | var desc = col.defaultDescSort; 159 | 160 | if (currentSort.index === i) { 161 | desc = !currentSort.desc; 162 | } 163 | sortByIndex(i, desc); 164 | removeSortIndicators(); 165 | currentSort.index = i; 166 | currentSort.desc = desc; 167 | addSortIndicators(); 168 | }; 169 | }; 170 | for (i = 0; i < cols.length; i += 1) { 171 | if (cols[i].sortable) { 172 | // add the click event handler on the th so users 173 | // dont have to click on those tiny arrows 174 | el = getNthColumn(i).querySelector('.sorter').parentElement; 175 | if (el.addEventListener) { 176 | el.addEventListener('click', ithSorter(i)); 177 | } else { 178 | el.attachEvent('onclick', ithSorter(i)); 179 | } 180 | } 181 | } 182 | } 183 | // adds sorting functionality to the UI 184 | return function() { 185 | if (!getTable()) { 186 | return; 187 | } 188 | cols = loadColumns(); 189 | loadData(); 190 | addSearchBox(); 191 | addSortIndicators(); 192 | enableUI(); 193 | }; 194 | })(); 195 | 196 | window.addEventListener('load', addSorting); 197 | -------------------------------------------------------------------------------- /coverage/lcov.info: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TelwareSW/telware-backend/4bc3129f1e28bbfc02b57516d8a47a3ec4550257/coverage/lcov.info -------------------------------------------------------------------------------- /dev.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | 3 | RUN mkdir /app 4 | 5 | WORKDIR /app 6 | 7 | COPY package*.json ./ 8 | 9 | RUN npm install 10 | 11 | COPY . . 12 | 13 | EXPOSE 3000 14 | 15 | CMD ["npm", "run", "dev"] 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /docs/PROJECT_FLOW.md: -------------------------------------------------------------------------------- 1 | # Project Flow Documentation 2 | 3 | This document describes the branching strategy, commit message conventions, and linear history maintenance for this project to ensure consistency and clarity across all contributions. 4 | 5 | --- 6 | 7 | ## :open_file_folder: Branching Strategy 8 | 9 | The project follows a **branch-based workflow** to organize features, fixes, and releases efficiently. Below is the branching strategy to be followed: 10 | 11 | ### Branch Types 12 | 13 | - **`main`** 14 | 15 | - Contains the latest stable release code. 16 | - Only merged after review and passing tests. 17 | - No direct commits allowed. 18 | 19 | - **`develop`** 20 | 21 | - Tracks ongoing development work. 22 | - Feature branches are merged here after approval. 23 | - Regularly rebased from `main` to keep it up-to-date. 24 | 25 | - **`feature/{feature-name}`** 26 | 27 | - Used for working on individual features. 28 | - Branch naming convention: `feature/login-ui` or `feature/integration-x-api`. 29 | 30 | - **`bugfix/{issue-number}-{description}`** 31 | 32 | - Dedicated for bug fixes. 33 | - Example: `bugfix/42-login-issue`. 34 | 35 | - **`release/{version}`** 36 | 37 | - Tracks release preparation (e.g., `release/1.0.0`). 38 | - Bug fixes and last-minute adjustments merged here. 39 | 40 | - **`hotfix/{issue-number}-{description}`** 41 | - Used for critical, time-sensitive patches to the `main` branch. 42 | - Example: `hotfix/99-crash-on-launch`. 43 | 44 | --- 45 | 46 | ### :card_index_dividers: Difference Between `develop` and `main` 47 | 48 | The **`develop`** and **`main`** branches serve distinct roles in the project: 49 | 50 | - **`main` Branch:** 51 | 52 | - **Purpose:** Holds the latest **stable, production-ready code**. 53 | - **Usage:** Reflects the **live version** of the project. Only tested changes are merged here, typically via release branches or hotfixes. Direct commits are not allowed. 54 | 55 | - **`develop` Branch:** 56 | - **Purpose:** Acts as the **integration branch** for new features and fixes. 57 | - **Usage:** Aggregates work from feature branches and may contain unstable code. Code is tested here before being merged into `main`. 58 | 59 | ### Key Differences 60 | 61 | | Aspect | `main` | `develop` | 62 | | ------------------ | ----------------------------- | --------------------------------------- | 63 | | **Purpose** | Production-ready code | Ongoing development and integration | 64 | | **Stability** | Always stable and deployable | May contain unstable or incomplete code | 65 | | **Direct Commits** | Not allowed (except hotfixes) | Allowed but discouraged | 66 | 67 | This strategy helps maintain a **stable production environment** while allowing flexibility for development. 68 | 69 | --- 70 | 71 | ## :bookmark_tabs: Commit Message Conventions 72 | 73 | Consistent commit messages help with clear versioning and history tracking. The following format ensures that every commit is meaningful. 74 | 75 | ### Commit Message Format 76 | 77 | ``` 78 | (): 79 | 80 | # Optional but recommended 81 | # Optional: references issues or breaking changes 82 | ``` 83 | 84 | #### **Examples:** 85 | 86 | - `feat(auth): add login page UI` 87 | - `fix(api): correct endpoint URL for orders` 88 | - `docs(readme): update installation instructions` 89 | 90 | ### **Commit Types:** 91 | 92 | - **feat:** A new feature 93 | - **fix:** A bug fix 94 | - **docs:** Documentation changes only 95 | - **style:** Code style improvements (e.g., formatting) 96 | - **refactor:** Code changes that neither fix a bug nor add a feature 97 | - **test:** Adding or improving tests 98 | - **chore:** Routine tasks like dependency updates 99 | 100 | ### **Scope:** 101 | 102 | Optional but recommended. Indicates the area of the codebase affected (e.g., `auth`, `ui`, `api`). 103 | 104 | ### **Subject:** 105 | 106 | A short summary (imperative form, present tense) describing the change. 107 | 108 | --- 109 | 110 | ## :arrows_counterclockwise: Linear History and Merge Strategy 111 | 112 | ### **Linear History:** 113 | 114 | To maintain a clean and linear Git history, we use **rebasing** rather than merging for feature branches. This ensures that all commits are orderly and follow a chronological sequence. 115 | 116 | 1. **Always rebase your branch** on the latest `develop` or `main` branch: 117 | 118 | ``` 119 | git checkout feature/my-new-feature 120 | git fetch origin 121 | git rebase origin/develop 122 | ``` 123 | 124 | 2. **Resolve conflicts** if any appear during the rebase: 125 | 126 | ``` 127 | # After resolving conflicts 128 | git add . 129 | git rebase --continue 130 | ``` 131 | 132 | 3. **Force push** to update your branch (because history is rewritten): 133 | ``` 134 | git push --force 135 | ``` 136 | 137 | --- 138 | 139 | ## :rocket: Pull Requests and Merging Process 140 | 141 | 1. **Open a Pull Request (PR)** 142 | 143 | - Target: `develop` for feature/bugfix branches 144 | - Target: `main` for hotfixes 145 | 146 | 2. **Review Process:** 147 | 148 | - At least one team member must review your PR. 149 | - Ensure all tests pass before requesting a review. 150 | 151 | 3. **Rebase if needed:** 152 | If `develop` has progressed since you started your feature, **rebase** your branch before merging. 153 | 154 | 4. **Squash and Merge:** 155 | Use **squash and merge** to ensure a linear history on the `develop` and `main` branches. 156 | 157 | 5. **Tagging Releases:** 158 | For releases, the maintainer will tag the `main` branch: 159 | ``` 160 | git tag -a v1.0.0 -m "Release version 1.0.0" 161 | git push origin v1.0.0 162 | ``` 163 | 164 | --- 165 | 166 | ## :hammer_and_wrench: Commands Summary 167 | 168 | - **Create a branch:** 169 | ``` 170 | git checkout -b feature/login-ui 171 | ``` 172 | - **Rebase your branch:** 173 | 174 | ``` 175 | git rebase origin/develop 176 | ``` 177 | 178 | - **Squash commits:** 179 | 180 | ``` 181 | git rebase -i HEAD~ 182 | ``` 183 | 184 | - **Push force after rebasing:** 185 | ``` 186 | git push --force 187 | ``` 188 | 189 | --- 190 | 191 | ## :clipboard: Best Practices 192 | 193 | - **Commit frequently:** Make small, incremental commits with meaningful messages. 194 | - **Keep PRs focused:** Avoid working on multiple issues in a single branch. 195 | - **Update branches regularly:** Rebase frequently to avoid large merge conflicts. 196 | - **Avoid direct commits to `main` or `develop`.** All changes should go through pull requests. 197 | -------------------------------------------------------------------------------- /docs/api/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TelwareSW/telware-backend/4bc3129f1e28bbfc02b57516d8a47a3ec4550257/docs/api/.gitkeep -------------------------------------------------------------------------------- /docs/api/message.swagger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @swagger 3 | * tags: 4 | * name: Sockets 5 | * description: The Sockets Managing API 6 | */ 7 | 8 | /** 9 | * @swagger 10 | * /PIN_MESSAGE_CLIENT: 11 | * post: 12 | * summary: Pins a message in a chat. 13 | * description: Marks a specific message as pinned in a chat. The pinned message information is sent to the server. 14 | * tags: [Sockets] 15 | * requestBody: 16 | * required: true 17 | * content: 18 | * application/json: 19 | * schema: 20 | * type: object 21 | * required: 22 | * - chatId 23 | * - messageId 24 | * - userId 25 | * properties: 26 | * chatId: 27 | * type: string 28 | * description: The unique ID of the chat where the message will be pinned. 29 | * messageId: 30 | * type: string 31 | * description: The unique ID of the message to be pinned. 32 | * userId: 33 | * type: string 34 | * description: The unique ID of the user performing the action. 35 | * example: "98765" 36 | */ 37 | 38 | /** 39 | * @swagger 40 | * /PIN_MESSAGE_SERVER: 41 | * post: 42 | * summary: Notifies clients about a pinned message in a chat. 43 | * description: Sends information to clients that a specific message has been pinned in a chat. 44 | * tags: [Sockets] 45 | * requestBody: 46 | * required: true 47 | * content: 48 | * application/json: 49 | * schema: 50 | * type: object 51 | * required: 52 | * - chatId 53 | * - messageId 54 | * - userId 55 | * properties: 56 | * chatId: 57 | * type: string 58 | * description: The unique ID of the chat where the message was pinned. 59 | * messageId: 60 | * type: string 61 | * description: The unique ID of the message that was pinned. 62 | * userId: 63 | * type: string 64 | * description: The unique ID of the user who performed the action. 65 | * example: "98765" 66 | */ 67 | -------------------------------------------------------------------------------- /docs/api/oauth.swagger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @swagger 3 | * tags: 4 | * name: OAuth 5 | * description: The OAuth Managing API 6 | */ 7 | 8 | /** 9 | * @swagger 10 | * /google: 11 | * get: 12 | * summary: Redirects the user to Google’s OAuth2.0 login page 13 | * tags: [OAuth] 14 | * description: Redirects the user to Google’s OAuth2.0 login page, requesting access to the user's profile and email. 15 | * responses: 16 | * 302: 17 | * description: Redirects the user to the Google login page. 18 | * headers: 19 | * Location: 20 | * type: string 21 | * description: The URL to which the user is redirected for Google authentication. 22 | * example: "https://accounts.google.com/o/oauth2/v2/auth?scope=profile+email&..." 23 | * 401: 24 | * description: Unauthorized if Google authentication fails. 25 | * content: 26 | * application/json: 27 | * schema: 28 | * type: object 29 | * properties: 30 | * status: 31 | * type: string 32 | * example: error 33 | * message: 34 | * type: string 35 | * example: "Authentication failed" 36 | */ 37 | 38 | /** 39 | * @swagger 40 | * /google/redirect: 41 | * get: 42 | * summary: Handles the OAuth callback from Google after authentication. 43 | * tags: [OAuth] 44 | * description: After successful authentication with Google, the user is redirected to either the frontend login page or a cross-platform redirect URL, depending on the origin. 45 | * responses: 46 | * 302: 47 | * description: Redirects the user to the appropriate URL after successful authentication. 48 | * headers: 49 | * Location: 50 | * type: string 51 | * description: The URL to which the user is redirected after Google authentication. 52 | * example: "https://yourfrontend.com/login?oauth=true" 53 | * 500: 54 | * description: Internal Server Error if session saving or redirecting fails. 55 | * content: 56 | * application/json: 57 | * schema: 58 | * type: object 59 | * properties: 60 | * status: 61 | * type: string 62 | * example: error 63 | * message: 64 | * type: string 65 | * example: "Internal server error occurred" 66 | */ 67 | 68 | /** 69 | * @swagger 70 | * /github: 71 | * get: 72 | * summary: Initiates GitHub authentication using Passport. 73 | * tags: [OAuth] 74 | * description: Redirects the user to GitHub's OAuth2.0 login page, requesting access to the user's email. 75 | * responses: 76 | * 302: 77 | * description: Redirects the user to GitHub's OAuth page for authentication. 78 | * headers: 79 | * Location: 80 | * type: string 81 | * description: The URL to which the user is redirected for GitHub authentication. 82 | * example: "https://github.com/login/oauth/authorize?scope=user%3Aemail&..." 83 | * 401: 84 | * description: Unauthorized if GitHub authentication fails. 85 | * content: 86 | * application/json: 87 | * schema: 88 | * type: object 89 | * properties: 90 | * status: 91 | * type: string 92 | * example: error 93 | * message: 94 | * type: string 95 | * example: "Authentication failed" 96 | */ 97 | 98 | /** 99 | * @swagger 100 | * /github/redirect: 101 | * get: 102 | * summary: Handles the OAuth callback from GitHub after authentication. 103 | * tags: [OAuth] 104 | * description: After successful authentication with GitHub, the user is redirected to either the frontend login page or a cross-platform redirect URL, depending on the origin. 105 | * responses: 106 | * 302: 107 | * description: Redirects the user to the appropriate URL after successful authentication. 108 | * headers: 109 | * Location: 110 | * type: string 111 | * description: The URL to which the user is redirected after GitHub authentication. 112 | * example: "https://yourfrontend.com/login?oauth=true" 113 | * 500: 114 | * description: Internal Server Error if session saving or redirecting fails. 115 | * content: 116 | * application/json: 117 | * schema: 118 | * type: object 119 | * properties: 120 | * status: 121 | * type: string 122 | * example: error 123 | * message: 124 | * type: string 125 | * example: "Internal server error occurred" 126 | */ 127 | -------------------------------------------------------------------------------- /docs/api/privacy.swagger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @swagger 3 | * /privacy/read-receipts: 4 | * patch: 5 | * tags: 6 | * - Privacy 7 | * summary: Toggle read receipts privacy 8 | * description: Enables or disables the read receipts privacy setting for the user. 9 | * responses: 10 | * '200': 11 | * description: Read receipts privacy updated successfully. 12 | * security: 13 | * - bearerAuth: [] 14 | */ 15 | 16 | /** 17 | * @swagger 18 | * /users/blocked: 19 | * get: 20 | * tags: 21 | * - User 22 | * summary: Get blocked users 23 | * description: Retrieves the list of users blocked by the authenticated user. 24 | * responses: 25 | * '200': 26 | * description: List of blocked users. 27 | * security: 28 | * - bearerAuth: [] 29 | */ 30 | 31 | /** 32 | * @swagger 33 | * /privacy/last-seen: 34 | * patch: 35 | * tags: 36 | * - Privacy 37 | * summary: Update last-seen privacy settings 38 | * description: Allows the user to update the privacy settings for their "last seen" status. 39 | * requestBody: 40 | * required: true 41 | * content: 42 | * application/json: 43 | * schema: 44 | * type: object 45 | * properties: 46 | * privacy: 47 | * type: string 48 | * enum: [contacts, everyone, nobody] 49 | * description: Privacy level for last-seen status. 50 | * example: contacts 51 | * responses: 52 | * '200': 53 | * description: Last-seen privacy updated successfully. 54 | * '400': 55 | * description: Invalid privacy option or bad request. 56 | * '404': 57 | * description: User not found. 58 | * security: 59 | * - bearerAuth: [] 60 | */ 61 | 62 | /** 63 | * @swagger 64 | * /privacy/picture: 65 | * patch: 66 | * tags: 67 | * - Privacy 68 | * summary: Update profile picture privacy settings 69 | * description: Allows the user to update the privacy settings for their profile picture. 70 | * requestBody: 71 | * required: true 72 | * content: 73 | * application/json: 74 | * schema: 75 | * type: object 76 | * properties: 77 | * privacy: 78 | * type: string 79 | * enum: [contacts, everyone, nobody] 80 | * description: Privacy level for profile picture. 81 | * example: nobody 82 | * responses: 83 | * '200': 84 | * description: Profile picture privacy updated successfully. 85 | * '400': 86 | * description: Invalid privacy option or bad request. 87 | * '404': 88 | * description: User not found. 89 | * security: 90 | * - bearerAuth: [] 91 | */ 92 | 93 | /** 94 | * @swagger 95 | * /privacy/invite-permissions: 96 | * patch: 97 | * tags: 98 | * - Privacy 99 | * summary: Update invite permissions privacy settings 100 | * description: Allows the user to update who can send them invites. 101 | * requestBody: 102 | * required: true 103 | * content: 104 | * application/json: 105 | * schema: 106 | * type: object 107 | * properties: 108 | * privacy: 109 | * type: string 110 | * description: Privacy level for invite permissions. 111 | * example: contacts 112 | * responses: 113 | * '200': 114 | * description: Invite permissions privacy updated successfully. 115 | * '400': 116 | * description: Invalid privacy option or bad request. 117 | * '404': 118 | * description: User not found. 119 | * security: 120 | * - bearerAuth: [] 121 | */ 122 | -------------------------------------------------------------------------------- /docs/api/story.swagger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @swagger 3 | * tags: 4 | * name: Story 5 | * description: The Story Managing API 6 | */ 7 | 8 | /** 9 | * @swagger 10 | * /stories/{storyId}/views: 11 | * post: 12 | * summary: Mark a story as viewed by the authenticated user 13 | * tags: [Story] 14 | * description: Adds the authenticated user to the list of users who have viewed the specified story. 15 | * parameters: 16 | * - name: storyId 17 | * in: path 18 | * required: true 19 | * description: The ID of the story to be marked as viewed. 20 | * schema: 21 | * type: string 22 | * example: "64f1d2d2c1234567890abcdef" 23 | * security: 24 | * - cookieAuth: [] 25 | * responses: 26 | * 200: 27 | * description: Successfully marked the story as viewed by the user. 28 | * content: 29 | * application/json: 30 | * schema: 31 | * type: object 32 | * properties: 33 | * status: 34 | * type: string 35 | * example: success 36 | * message: 37 | * type: string 38 | * example: "User viewed the story successfully" 39 | * data: 40 | * type: object 41 | * properties: {} 42 | * 400: 43 | * description: Invalid story ID or request data. 44 | * content: 45 | * application/json: 46 | * schema: 47 | * type: object 48 | * properties: 49 | * status: 50 | * type: string 51 | * example: fail 52 | * message: 53 | * type: string 54 | * example: "Invalid story ID" 55 | * 404: 56 | * description: Story not found. 57 | * content: 58 | * application/json: 59 | * schema: 60 | * type: object 61 | * properties: 62 | * status: 63 | * type: string 64 | * example: fail 65 | * message: 66 | * type: string 67 | * example: "No story exists with this ID" 68 | * 401: 69 | * description: Unauthorized, user not logged in or session expired. 70 | * content: 71 | * application/json: 72 | * schema: 73 | * type: object 74 | * properties: 75 | * status: 76 | * type: string 77 | * example: fail 78 | * message: 79 | * type: string 80 | * example: "Session not found, you are not allowed here!" 81 | */ 82 | -------------------------------------------------------------------------------- /docs/functions/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /docs/functions/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-code-background: #FFFFFF; 3 | --dark-code-background: #1E1E1E; 4 | } 5 | 6 | @media (prefers-color-scheme: light) { :root { 7 | --code-background: var(--light-code-background); 8 | } } 9 | 10 | @media (prefers-color-scheme: dark) { :root { 11 | --code-background: var(--dark-code-background); 12 | } } 13 | 14 | :root[data-theme='light'] { 15 | --code-background: var(--light-code-background); 16 | } 17 | 18 | :root[data-theme='dark'] { 19 | --code-background: var(--dark-code-background); 20 | } 21 | 22 | pre, code { background: var(--code-background); } 23 | -------------------------------------------------------------------------------- /docs/functions/assets/navigation.js: -------------------------------------------------------------------------------- 1 | window.navigationData = "data:application/octet-stream;base64,H4sIAAAAAAAAA4uOBQApu0wNAgAAAA==" -------------------------------------------------------------------------------- /docs/functions/assets/search.js: -------------------------------------------------------------------------------- 1 | window.searchData = "data:application/octet-stream;base64,H4sIAAAAAAAAA6tWKsovL1ayio7VUcrMS0mtULKqVipLLSrOzM9TslIy0jPWs1TSUUrLTM1JASlTykvMTQUKJOfn5qbmlQBZKfnJpWBmLFRZWGpySX4R3EygYSWpKZ4Qs0FCBZkFqTmZeakgXm0tACJTfzuBAAAA"; -------------------------------------------------------------------------------- /docs/functions/index.html: -------------------------------------------------------------------------------- 1 | telware-backend

telware-backend

telware backend

Backend Repo for TelWare Messaging Platform

2 |
3 | -------------------------------------------------------------------------------- /docs/functions/modules.html: -------------------------------------------------------------------------------- 1 | telware-backend

telware-backend

2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | /** @type {import('jest').Config} */ 7 | const config = { 8 | // All imported modules in your tests should be mocked automatically 9 | // automock: false, 10 | 11 | // Stop running tests after `n` failures 12 | // bail: 0, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/tmp/jest_rs", 16 | 17 | // Automatically clear mock calls, instances, contexts and results before every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | collectCoverage: true, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: undefined, 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: 'coverage', 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // Indicates which provider should be used to instrument code for coverage 35 | coverageProvider: 'v8', 36 | 37 | // A list of reporter names that Jest uses when writing coverage reports 38 | // coverageReporters: [ 39 | // "json", 40 | // "text", 41 | // "lcov", 42 | // "clover" 43 | // ], 44 | 45 | // An object that configures minimum threshold enforcement for coverage results 46 | // coverageThreshold: undefined, 47 | 48 | // A path to a custom dependency extractor 49 | // dependencyExtractor: undefined, 50 | 51 | // Make calling deprecated APIs throw helpful error messages 52 | // errorOnDeprecated: false, 53 | 54 | // The default configuration for fake timers 55 | // fakeTimers: { 56 | // "enableGlobally": false 57 | // }, 58 | 59 | // Force coverage collection from ignored files using an array of glob patterns 60 | // forceCoverageMatch: [], 61 | 62 | // A path to a module which exports an async function that is triggered once before all test suites 63 | // globalSetup: undefined, 64 | 65 | // A path to a module which exports an async function that is triggered once after all test suites 66 | // globalTeardown: undefined, 67 | 68 | // A set of global variables that need to be available in all test environments 69 | // globals: {}, 70 | 71 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 72 | // maxWorkers: "50%", 73 | 74 | // An array of directory names to be searched recursively up from the requiring module's location 75 | moduleDirectories: ['node_modules', 'src'], 76 | 77 | // An array of file extensions your modules use 78 | // moduleFileExtensions: [ 79 | // "js", 80 | // "mjs", 81 | // "cjs", 82 | // "jsx", 83 | // "ts", 84 | // "tsx", 85 | // "json", 86 | // "node" 87 | // ], 88 | 89 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 90 | moduleNameMapper: { 91 | '^@services/(.*)$': '/src/services/$1', 92 | '^@models/(.*)$': '/src/models/$1', 93 | '^@utils/(.*)$': '/src/utils/$1', 94 | '^@config/(.*)$': '/src/config/$1', 95 | '^@base/(.*)$': '/src/$1', 96 | '^@errors/(.*)$': '/src/errors/$1', 97 | '^@routes/(.*)$': '/src/routes/$1', 98 | '^@controllers/(.*)$': '/src/controllers/$1', 99 | '^@middlewares/(.*)$': '/src/middlewares/$1', 100 | }, 101 | setupFiles: ['tsconfig-paths/register'], 102 | testTimeout: 10000, 103 | 104 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 105 | // modulePathIgnorePatterns: [], 106 | 107 | // Activates notifications for test results 108 | // notify: false, 109 | 110 | // An enum that specifies notification mode. Requires { notify: true } 111 | // notifyMode: "failure-change", 112 | 113 | // A preset that is used as a base for Jest's configuration 114 | preset: 'ts-jest', 115 | 116 | // Run tests from one or more projects 117 | // projects: undefined, 118 | 119 | // Use this configuration option to add custom reporters to Jest 120 | // reporters: undefined, 121 | 122 | // Automatically reset mock state before every test 123 | // resetMocks: false, 124 | 125 | // Reset the module registry before running each individual test 126 | // resetModules: false, 127 | 128 | // A path to a custom resolver 129 | // resolver: undefined, 130 | 131 | // Automatically restore mock state and implementation before every test 132 | // restoreMocks: false, 133 | 134 | // The root directory that Jest should scan for tests and modules within 135 | // rootDir: undefined, 136 | 137 | // A list of paths to directories that Jest should use to search for files in 138 | // roots: [ 139 | // "" 140 | // ], 141 | 142 | // Allows you to use a custom runner instead of Jest's default test runner 143 | // runner: "jest-runner", 144 | 145 | // The paths to modules that run some code to configure or set up the testing environment before each test 146 | // setupFiles: [], 147 | 148 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 149 | // setupFilesAfterEnv: [], 150 | 151 | // The number of seconds after which a test is considered as slow and reported as such in the results. 152 | // slowTestThreshold: 5, 153 | 154 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 155 | // snapshotSerializers: [], 156 | 157 | // The test environment that will be used for testing 158 | testEnvironment: 'node', 159 | // Options that will be passed to the testEnvironment 160 | // testEnvironmentOptions: {}, 161 | 162 | // Adds a location field to test results 163 | // testLocationInResults: false, 164 | 165 | // The glob patterns Jest uses to detect test files 166 | testMatch: ['**/src/tests/**/*.test.ts'], 167 | 168 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 169 | // testPathIgnorePatterns: [ 170 | // "/node_modules/" 171 | // ], 172 | 173 | // The regexp pattern or array of patterns that Jest uses to detect test files 174 | // testRegex: [], 175 | 176 | // This option allows the use of a custom results processor 177 | // testResultsProcessor: undefined, 178 | 179 | // This option allows use of a custom test runner 180 | // testRunner: "jest-circus/runner", 181 | 182 | // A map from regular expressions to paths to transformers 183 | // transform: undefined, 184 | 185 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 186 | // transformIgnorePatterns: [ 187 | // "/node_modules/", 188 | // "\\.pnp\\.[^\\/]+$" 189 | // ], 190 | 191 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 192 | // unmockedModulePathPatterns: undefined, 193 | 194 | // Indicates whether each individual test should be reported during the run 195 | // verbose: undefined, 196 | 197 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 198 | // watchPathIgnorePatterns: [], 199 | 200 | // Whether to use watchman for file crawling 201 | // watchman: true, 202 | }; 203 | 204 | module.exports = config; 205 | -------------------------------------------------------------------------------- /migrate-mongo-config.js: -------------------------------------------------------------------------------- 1 | // In this file you can configure migrate-mongo 2 | 3 | const config = { 4 | mongodb: { 5 | url: 'mongodb://mongo:27017', 6 | databaseName: 'telwareDB', 7 | 8 | options: { 9 | useNewUrlParser: true, // removes a deprecation warning when connecting 10 | useUnifiedTopology: true, // removes a deprecating warning when connecting 11 | }, 12 | }, 13 | 14 | // The migrations dir, can be an relative or absolute path. Only edit this when really necessary. 15 | migrationsDir: 'src/database/migrations', 16 | 17 | // The mongodb collection where the applied changes are stored. Only edit this when really necessary. 18 | changelogCollectionName: 'changelog', 19 | 20 | // The file extension to create migrations and search for in migration dir 21 | migrationFileExtension: '.js', 22 | 23 | // Enable the algorithm to create a checksum of the file contents and use that in the comparison to determine 24 | // if the file should be run. Requires that scripts are coded to be run multiple times. 25 | useFileHash: false, 26 | 27 | // Don't change this, unless you know what you're doing 28 | moduleSystem: 'commonjs', 29 | }; 30 | 31 | module.exports = config; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "telware-backend", 3 | "version": "1.0.0", 4 | "description": "Backend Repo for TelWare Project", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "npx tsc --project tsconfig.json && tsc-alias -p tsconfig.json", 8 | "start": "NODE_ENV=production node dist/index.js", 9 | "dev": "npx nodemon -r tsconfig-paths/register src/index.ts", 10 | "seed": "npx ts-node -r tsconfig-paths/register src/database/seed/seed.ts --import", 11 | "drop": "npx ts-node -r tsconfig-paths/register src/database/seed/seed.ts --delete", 12 | "test": "npx jest", 13 | "test:watch": "npx jest --watchAll", 14 | "test:coverage": "npx jest --coverage", 15 | "doc": "npx typedoc", 16 | "migrate:create": "npx migrate-mongo create", 17 | "migrate:up": "npx migrate-mongo up", 18 | "migrate:down": "npx migrate-mongo down", 19 | "lint": "npx eslint .", 20 | "lint:fix": "npx eslint --fix .", 21 | "format": "npx prettier --write .", 22 | "static-analysis": "node src/utils/static-analysis-script.mjs" 23 | }, 24 | "author": "TelWare", 25 | "license": "ISC", 26 | "devDependencies": { 27 | "@babel/core": "^7.26.0", 28 | "@babel/preset-env": "^7.26.0", 29 | "@babel/preset-typescript": "^7.26.0", 30 | "@types/axios": "^0.14.4", 31 | "@types/bcrypt": "^5.0.2", 32 | "@types/cors": "^2.8.17", 33 | "@types/express": "^5.0.0", 34 | "@types/express-session": "^1.18.0", 35 | "@types/hpp": "^0.2.6", 36 | "@types/jest": "^29.5.14", 37 | "@types/jsonwebtoken": "^9.0.7", 38 | "@types/morgan": "^1.9.9", 39 | "@types/multer": "^1.4.12", 40 | "@types/node": "^22.7.9", 41 | "@types/nodemailer": "^6.4.16", 42 | "@types/passport": "^1.0.17", 43 | "@types/passport-github2": "^1.2.9", 44 | "@types/passport-google-oauth20": "^2.0.16", 45 | "@types/swagger-jsdoc": "^6.0.4", 46 | "@types/swagger-ui-express": "^4.1.7", 47 | "@types/ua-parser-js": "^0.7.39", 48 | "@types/validator": "^13.12.2", 49 | "@types/yamljs": "^0.2.34", 50 | "@typescript-eslint/eslint-plugin": "^8.8.1", 51 | "@typescript-eslint/parser": "^8.8.1", 52 | "babel-jest": "^29.7.0", 53 | "concurrently": "^9.0.1", 54 | "eslint": "^8.57.1", 55 | "eslint-config-airbnb": "^19.0.4", 56 | "eslint-config-prettier": "^9.1.0", 57 | "eslint-import-resolver-typescript": "^3.6.3", 58 | "eslint-plugin-import": "^2.31.0", 59 | "eslint-plugin-jest": "^28.8.3", 60 | "eslint-plugin-jsx-a11y": "^6.10.0", 61 | "eslint-plugin-node": "^11.1.0", 62 | "eslint-plugin-prettier": "^5.2.1", 63 | "eslint-plugin-react": "^7.37.1", 64 | "eslint-plugin-react-hooks": "^4.6.2", 65 | "globals": "^15.11.0", 66 | "jest": "^29.7.0", 67 | "migrate-mongo": "^11.0.0", 68 | "nodemon": "^3.1.7", 69 | "prettier": "^3.3.3", 70 | "ts-jest": "^29.2.5", 71 | "ts-node": "^10.9.2", 72 | "ts-node-dev": "^2.0.0", 73 | "tsc-alias": "^1.8.10", 74 | "tsconfig-paths": "^4.2.0", 75 | "tsx": "^4.19.1", 76 | "typedoc": "^0.26.11", 77 | "typescript": "^5.6.3", 78 | "typescript-eslint": "^8.8.1" 79 | }, 80 | "dependencies": { 81 | "@faker-js/faker": "^9.0.3", 82 | "@google/generative-ai": "^0.21.0", 83 | "@huggingface/inference": "^2.8.1", 84 | "axios": "^1.7.9", 85 | "bcrypt": "^5.1.1", 86 | "body-parser": "^1.20.3", 87 | "connect-redis": "^7.1.1", 88 | "cors": "^2.8.5", 89 | "crypto": "^1.0.1", 90 | "dotenv": "^16.4.5", 91 | "express": "^4.21.1", 92 | "express-mongo-sanitize": "^2.2.0", 93 | "express-rate-limit": "^7.4.1", 94 | "express-session": "^1.18.1", 95 | "firebase-admin": "^13.0.2", 96 | "helmet": "^8.0.0", 97 | "hpp": "^0.2.3", 98 | "jsonwebtoken": "^9.0.2", 99 | "module-alias": "^2.2.3", 100 | "mongodb": "^6.11.0", 101 | "mongoose": "^8.7.2", 102 | "morgan": "^1.10.0", 103 | "multer": "^1.4.5-lts.1", 104 | "node-fetch": "^3.3.2", 105 | "nodemailer": "^6.9.15", 106 | "npm": "^10.9.1", 107 | "passport": "^0.7.0", 108 | "passport-github2": "^0.1.12", 109 | "passport-google-oauth20": "^2.0.0", 110 | "redis": "^4.7.0", 111 | "request": "^2.88.2", 112 | "socket.io": "^4.8.1", 113 | "supertest": "^7.0.0", 114 | "swagger-jsdoc": "^6.2.8", 115 | "swagger-ui-express": "^5.0.1", 116 | "telware-backend": "file:", 117 | "ua-parser-js": "^1.0.39", 118 | "validator": "^13.12.0", 119 | "yamljs": "^0.3.0" 120 | }, 121 | "directories": { 122 | "doc": "docs" 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /prod.Dockerfile: -------------------------------------------------------------------------------- 1 | # Build Stage 2 | FROM node:22.11.0-bookworm-slim AS build 3 | 4 | WORKDIR /app 5 | 6 | COPY package.json package-lock.json ./ 7 | 8 | RUN npm ci 9 | 10 | COPY . . 11 | 12 | RUN npm run build 13 | 14 | RUN npm prune --omit=dev 15 | 16 | 17 | # Run Stage 18 | FROM node:22.11.0-bookworm-slim AS run 19 | 20 | RUN mkdir -p /home/node/app 21 | 22 | COPY --from=build --chown=node:node /app . 23 | 24 | USER node 25 | 26 | CMD ["node", "dist/index.js"] 27 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Default command 4 | DEFAULT_CMD="docker compose up --attach backend" 5 | 6 | # Function to show usage 7 | usage() { 8 | echo 9 | echo "Usage: $0 [backend|all|build|seed|drop|close|background|help]" 10 | echo "Options:" 11 | echo " backend Run 'docker compose up --attach backend' (start only the backend service)" 12 | echo " all Run 'docker compose up' (start all services)" 13 | echo " build Run 'docker compose up --build' (build images before starting)" 14 | echo " seed Run 'docker compose up -d' and seed the database" 15 | echo " drop Run 'docker compose up -d' and drop the database" 16 | echo " close Run 'docker compose down' (stop all services)" 17 | echo " background Run 'docker compose up -d' (start all services in background)" 18 | echo " help Show this help message" 19 | echo "By default, the script runs the command: docker compose up --attach backend" 20 | echo 21 | exit 1 22 | } 23 | 24 | # Parse the first argument 25 | case "$1" in 26 | backend) 27 | echo "Running: docker compose up --attach backend" 28 | docker compose up --attach backend 29 | ;; 30 | all) 31 | echo "Running: docker compose up" 32 | docker compose up 33 | ;; 34 | build) 35 | echo "Running: docker compose up --build" 36 | docker compose up --build 37 | ;; 38 | seed) 39 | echo "Running: docker compose up -d && npm run seed" 40 | docker compose up -d 41 | docker exec -it telware-backend-backend-1 npm run seed 42 | ;; 43 | drop) 44 | echo "Running: docker compose up -d && npm run drop" 45 | docker compose up -d 46 | docker exec -it telware-backend-backend-1 npm run drop 47 | ;; 48 | close) 49 | echo "Running: docker compose down" 50 | docker compose down 51 | ;; 52 | background) 53 | echo "Running: docker compose up -d" 54 | docker compose up -d 55 | ;; 56 | help) 57 | usage 58 | ;; 59 | "" ) 60 | echo "Running default: $DEFAULT_CMD" 61 | $DEFAULT_CMD 62 | ;; 63 | *) 64 | echo "Invalid option: $1" 65 | usage 66 | ;; 67 | esac 68 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import morgan from 'morgan'; 3 | import cors from 'cors'; 4 | import passport from 'passport'; 5 | import rateLimit from 'express-rate-limit'; 6 | import helmet from 'helmet'; 7 | import mongoSanitize from 'express-mongo-sanitize'; 8 | import hpp from 'hpp'; 9 | 10 | import swaggerUI from 'swagger-ui-express'; 11 | import swaggerJsDoc from 'swagger-jsdoc'; 12 | 13 | import AppError from '@errors/AppError'; 14 | import globalErrorHandler from '@errors/globalErrorHandler'; 15 | import apiRouter from '@routes/apiRoute'; 16 | import path from 'path'; 17 | import corsOptions from '@config/cors'; 18 | import sessionMiddleware from '@config/session'; 19 | 20 | const app = express(); 21 | 22 | const swaggerOptions = { 23 | swaggerDefinition: { 24 | openapi: '3.0.0', 25 | info: { 26 | title: 'Telware Backend API', 27 | description: 'API Documentation for Telware Backend', 28 | version: '1.0.0', 29 | contact: { 30 | email: 'telware.sw@gmail.com', 31 | }, 32 | license: { 33 | name: 'Apache 2.0', 34 | url: 'http://apache.org/', 35 | }, 36 | }, 37 | servers: [ 38 | { 39 | url: `${process.env.SERVER_URL}/api/v1`, 40 | description: 'HTTP server', 41 | }, 42 | { 43 | url: process.env.WEBSOCKET_URL, 44 | description: 'WebSocket server', 45 | }, 46 | ], 47 | }, 48 | apis: [`${__dirname}/../docs/api/*.ts`], 49 | }; 50 | 51 | const swaggerDocs = swaggerJsDoc(swaggerOptions); 52 | app.use('/api-docs', swaggerUI.serve, swaggerUI.setup(swaggerDocs)); 53 | 54 | app.use('/static', express.static(path.join(process.cwd(), 'src/public'))); 55 | app.use(cors(corsOptions)); 56 | app.options('*', cors(corsOptions)); 57 | app.use(express.json({ limit: '10kb' })); 58 | app.use(express.urlencoded({ extended: true })); 59 | app.use(sessionMiddleware); 60 | 61 | app.use(passport.initialize()); 62 | app.use(passport.session()); 63 | 64 | const limiter = rateLimit({ 65 | max: 100, 66 | windowMs: 60 * 60 * 1000, 67 | message: 68 | 'Too many requests from the same IP! Please try again later in an hour', 69 | }); 70 | if (process.env.NODE_ENV === 'production') { 71 | app.use('/api', limiter); 72 | } 73 | 74 | // Set some HTTP response headers to increase security 75 | app.use(helmet()); 76 | 77 | // NoSQL injection attack 78 | app.use(mongoSanitize()); 79 | 80 | // Prevent parameter pollution 81 | app.use(hpp(/* { whitelist: [...] } */)); 82 | 83 | // TODO: Protect against cross-site scripting attack 84 | 85 | if (process.env.NODE_ENV === 'development') { 86 | app.use(morgan('dev')); 87 | } 88 | 89 | app.use('/api/v1', apiRouter); 90 | 91 | app.all('*', (req, res, next) => { 92 | next(new AppError(`${req.originalUrl} - Not Found!`, 404)); 93 | }); 94 | 95 | app.use(globalErrorHandler); 96 | 97 | export default app; 98 | -------------------------------------------------------------------------------- /src/config/allowedOrigins.json: -------------------------------------------------------------------------------- 1 | [ 2 | "http://localhost:3000", 3 | "http://localhost:5174", 4 | "http://127.0.0.1:5174", 5 | "http://telware.tech", 6 | "http://dev.telware.tech", 7 | "http://testing.telware.tech", 8 | "http://api.telware.tech", 9 | "http://api.testing.telware.tech", 10 | "https://localhost:3000", 11 | "https://localhost:5174", 12 | "https://127.0.0.1:5174", 13 | "https://telware.tech", 14 | "https://dev.telware.tech", 15 | "https://testing.telware.tech", 16 | "https://api.telware.tech", 17 | "https://api.testing.telware.tech" 18 | ] 19 | -------------------------------------------------------------------------------- /src/config/cors.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | 3 | const allowedOrigins = JSON.parse( 4 | readFileSync(`${__dirname}/allowedOrigins.json`, 'utf8') 5 | ); 6 | 7 | const corsOptions = { 8 | origin: allowedOrigins, 9 | methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], 10 | credentials: true, 11 | withCredentials: true, 12 | exposedHeaders: ['set-cookie'], 13 | }; 14 | 15 | export default corsOptions; 16 | -------------------------------------------------------------------------------- /src/config/env.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | 3 | dotenv.config(); -------------------------------------------------------------------------------- /src/config/fileUploads.ts: -------------------------------------------------------------------------------- 1 | import multer from 'multer'; 2 | import path from 'path'; 3 | import crypto from 'crypto'; 4 | 5 | // Define storage for uploaded files 6 | const storage = multer.diskStorage({ 7 | destination: (req, file, cb) => { 8 | cb(null, path.join(process.cwd(), 'src/public/media')); 9 | }, 10 | filename: (req, file, cb) => { 11 | const uniqueSuffix = crypto.randomBytes(8).toString('hex'); 12 | const ext = path.extname(file.originalname); 13 | const fileName = `${uniqueSuffix}${ext}`; 14 | 15 | cb(null, fileName); 16 | }, 17 | }); 18 | 19 | const upload = multer({ 20 | storage, 21 | limits: { fileSize: 5 * 1024 * 1024 }, // 5MB 22 | }); 23 | 24 | export default upload; 25 | -------------------------------------------------------------------------------- /src/config/firebase.ts: -------------------------------------------------------------------------------- 1 | import admin from 'firebase-admin'; 2 | 3 | const serviceAccount = JSON.parse( 4 | process.env.FIREBASE_SERVICE_ACCOUNT as string 5 | ); 6 | 7 | admin.initializeApp({ 8 | credential: admin.credential.cert(serviceAccount), 9 | }); 10 | 11 | export const messaging = admin.messaging(); 12 | 13 | export default admin; 14 | -------------------------------------------------------------------------------- /src/config/mongoDB.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const DB_DOCKER_URI: string | undefined = 4 | process.env.MONGO_DB_DOCKER_URL?.replace( 5 | '{USER}', 6 | process.env.MONGO_DB_USER as string 7 | ).replace('{PASSWORD}', process.env.MONGO_DB_PASSWORD as string); 8 | 9 | const DB_URI = 10 | process.env.ENV === 'localhost' 11 | ? (process.env.MONGO_DB_LOCALHOST_URL as string) 12 | : (DB_DOCKER_URI as string); 13 | 14 | mongoose.connection.on('error', (err) => { 15 | console.error('MongoDB connection error:', err); 16 | }); 17 | 18 | async function mongoDBConnection() { 19 | try { 20 | await mongoose.connect(DB_URI); 21 | console.log('Connected successfuly to MongoDB server !'); 22 | } catch (err) { 23 | console.log('Failed to connect to database :('); 24 | console.log(err); 25 | } 26 | } 27 | 28 | export default mongoDBConnection; 29 | -------------------------------------------------------------------------------- /src/config/passport.ts: -------------------------------------------------------------------------------- 1 | import passport from 'passport'; 2 | import axios from 'axios'; 3 | import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; 4 | import { 5 | Strategy as GitHubStrategy, 6 | Profile as GitHubProfile, 7 | } from 'passport-github2'; 8 | import { createOAuthUser } from '@services/authService'; 9 | import IUser from '@base/types/user'; 10 | 11 | passport.serializeUser((user: any, done) => { 12 | done(null, user.id); 13 | }); 14 | 15 | passport.deserializeUser(async (id, done) => { 16 | try { 17 | done(null, { id }); 18 | } catch (error) { 19 | done(error); 20 | } 21 | }); 22 | 23 | passport.use( 24 | new GoogleStrategy( 25 | { 26 | clientID: process.env.GOOGLE_CLIENT_ID as string, 27 | clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, 28 | callbackURL: '/api/v1/auth/oauth/google/redirect', 29 | scope: ['profile', 'email'], 30 | }, 31 | async (accessToken, refreshToken, profile, done) => { 32 | try { 33 | const user: IUser = await createOAuthUser(profile); 34 | done(null, user); 35 | } catch (error) { 36 | console.log(error); 37 | done(error); 38 | } 39 | } 40 | ) 41 | ); 42 | 43 | passport.use( 44 | new GitHubStrategy( 45 | { 46 | clientID: process.env.GITHUB_CLIENT_ID as string, 47 | clientSecret: process.env.GITHUB_CLIENT_SECRET as string, 48 | callbackURL: '/api/v1/auth/oauth/github/redirect', 49 | scope: ['user:email'], 50 | }, 51 | async ( 52 | accessToken: string, 53 | refreshToken: string, 54 | profile: GitHubProfile, 55 | done: (_error: any, _user?: any, _info?: any) => void 56 | ) => { 57 | try { 58 | const emailResponse = await axios.get( 59 | 'https://api.github.com/user/emails', 60 | { 61 | headers: { 62 | Authorization: `token ${accessToken}`, 63 | }, 64 | } 65 | ); 66 | const email = emailResponse.data.find( 67 | (em: any) => em.primary && em.verified 68 | )?.email; 69 | const user: IUser = await createOAuthUser(profile, email); 70 | done(null, user); 71 | } catch (error) { 72 | console.log(error); 73 | done(error); 74 | } 75 | } 76 | ) 77 | ); 78 | -------------------------------------------------------------------------------- /src/config/redis.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from 'redis'; 2 | 3 | const REDIS_URI = 4 | process.env.ENV === 'localhost' 5 | ? process.env.REDIS_LOCALHOST_URL 6 | : process.env.REDIS_DOCKER_URL; 7 | 8 | const redisClient = createClient({ 9 | url: REDIS_URI, 10 | }); 11 | 12 | redisClient.on('error', (err) => console.log('Redis Client Error', err)); 13 | 14 | redisClient 15 | .connect() 16 | .then(() => console.log('Connected successfuly to redis server !')) 17 | .catch((err) => console.log(err)); 18 | 19 | export default redisClient; 20 | -------------------------------------------------------------------------------- /src/config/session.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongoose'; 2 | import session from 'express-session'; 3 | import RedisStore from 'connect-redis'; 4 | 5 | import { generateSession } from '@services/sessionService'; 6 | import redisClient from '@config/redis'; 7 | 8 | declare module 'express-session' { 9 | // eslint-disable-next-line no-unused-vars 10 | interface SessionData { 11 | user: { 12 | id: ObjectId; 13 | timestamp: number; 14 | lastSeenTime: number; 15 | status: 'online' | 'offline'; 16 | agent?: { 17 | device?: string; 18 | os?: string; 19 | browser?: string; 20 | }; 21 | }; 22 | } 23 | } 24 | 25 | const maxAge = 26 | parseInt(process.env.SESSION_EXPIRES_IN as string, 10) * 24 * 60 * 60 * 1000; 27 | 28 | const sessionMiddleware = session({ 29 | store: new RedisStore({ client: redisClient, ttl: maxAge / 1000 }), 30 | secret: process.env.SESSION_SECRET as string, 31 | resave: false, 32 | saveUninitialized: false, 33 | genid: generateSession, 34 | cookie: { 35 | maxAge, 36 | httpOnly: true, 37 | sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', 38 | secure: process.env.NODE_ENV === 'production', 39 | path: '/', 40 | }, 41 | }); 42 | 43 | export default sessionMiddleware; 44 | -------------------------------------------------------------------------------- /src/controllers/handlerFactory.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { Model } from 'mongoose'; 3 | import AppError from '@errors/AppError'; 4 | import catchAsync from '@utils/catchAsync'; 5 | 6 | const factory = { 7 | deleteOne: (model: Model, modelName: String) => 8 | catchAsync(async (req: Request, res: Response, next: NextFunction) => { 9 | const document = await model.findByIdAndDelete(req.params.id); 10 | 11 | if (!document) { 12 | return next(new AppError(`No ${modelName} exists with this ID`, 404)); 13 | } 14 | 15 | res.status(204).json({ 16 | status: 'success', 17 | message: `${modelName} deleted successfully`, 18 | data: null, 19 | }); 20 | }), 21 | }; 22 | 23 | export default factory; 24 | -------------------------------------------------------------------------------- /src/controllers/privacyController.ts: -------------------------------------------------------------------------------- 1 | import catchAsync from '@utils/catchAsync'; 2 | import User from '@models/userModel'; 3 | import { Response, NextFunction } from 'express'; 4 | import mongoose from 'mongoose'; 5 | import AppError from '@errors/AppError'; 6 | 7 | export const getBlockedUsers = catchAsync( 8 | async (req: any, res: Response, next: NextFunction) => { 9 | const userId = req.user.id; // Blocker ID 10 | 11 | const user = await User.findById(userId).populate( 12 | 'blockedUsers', 13 | 'username email' 14 | ); 15 | if (!user) { 16 | return next(new AppError('User not found', 404)); 17 | } 18 | 19 | res.status(200).json({ 20 | status: 'success', 21 | message: 'Blocked users fetched successfully', 22 | data: { 23 | users: user.blockedUsers, 24 | }, 25 | }); 26 | } 27 | ); 28 | 29 | export const block = catchAsync( 30 | async (req: any, res: Response, next: NextFunction) => { 31 | const userId = req.user.id; // Blocker ID 32 | const targetUserId = req.params.id; // User ID to block 33 | 34 | if (!mongoose.Types.ObjectId.isValid(targetUserId)) 35 | return next(new AppError('Invalid user ID', 400)); 36 | 37 | const user = await User.findByIdAndUpdate( 38 | userId, 39 | { $addToSet: { blockedUsers: targetUserId } }, 40 | { new: true, runValidators: true } 41 | ); 42 | 43 | if (!user) return next(new AppError('User not found', 404)); 44 | 45 | res.status(200).json({ 46 | status: 'success', 47 | message: 'User blocked successfully', 48 | data: { 49 | users: user.blockedUsers, 50 | }, 51 | }); 52 | } 53 | ); 54 | 55 | export const unblock = catchAsync( 56 | async (req: any, res: Response, next: NextFunction) => { 57 | const userId = req.user.id; 58 | const targetUserId = req.params.id; 59 | 60 | if (!mongoose.Types.ObjectId.isValid(targetUserId)) { 61 | return next(new AppError('Invalid user ID', 400)); 62 | } 63 | 64 | const user = await User.findByIdAndUpdate( 65 | userId, 66 | { $pull: { blockedUsers: targetUserId } }, 67 | { new: true, runValidators: true } 68 | ); 69 | 70 | if (!user) { 71 | return next(new AppError('User not found', 404)); 72 | } 73 | 74 | res.status(200).json({ 75 | status: 'success', 76 | message: 'User unblocked successfullly', 77 | data: { 78 | users: user.blockedUsers, 79 | }, 80 | }); 81 | } 82 | ); 83 | 84 | export const switchReadRecieptsState = catchAsync( 85 | async (req: any, res: Response, next: NextFunction) => { 86 | const userId = req.user.id; 87 | const user = await User.findById(userId); 88 | if (!user) { 89 | return next(new AppError('User not found', 404)); 90 | } 91 | const updatedUser = await User.findByIdAndUpdate( 92 | userId, 93 | { $set: { readReceiptsEnablePrivacy: !user.readReceiptsEnablePrivacy } }, 94 | { new: true, runValidators: true } 95 | ); 96 | if (!updatedUser) { 97 | return next(new AppError('User not found', 404)); 98 | } 99 | res.status(200).json({ 100 | status: 'success', 101 | message: 'Read receipts privacy updated successfully', 102 | data: {}, 103 | }); 104 | } 105 | ); 106 | 107 | export const changeStoriesPrivacy = catchAsync( 108 | async (req: any, res: Response, next: NextFunction) => { 109 | const userId = req.user.id; 110 | const { privacy } = req.body; 111 | if ( 112 | privacy !== 'contacts' && 113 | privacy !== 'everyone' && 114 | privacy !== 'nobody' 115 | ) { 116 | return next( 117 | new AppError( 118 | 'Invalid privacy option. Choose contacts, everyone, or nobody.', 119 | 400 120 | ) 121 | ); 122 | } 123 | const user = await User.findByIdAndUpdate( 124 | userId, 125 | { $set: { storiesPrivacy: privacy } }, 126 | { new: true, runValidators: true } 127 | ); 128 | if (!user) { 129 | throw new Error('User not found'); 130 | } 131 | res.status(200).json({ 132 | status: 'success', 133 | message: 'Stories privacy updated successfully', 134 | data: {}, 135 | }); 136 | } 137 | ); 138 | export const changeLastSeenPrivacy = catchAsync( 139 | async (req: any, res: Response, next: NextFunction) => { 140 | const userId = req.user.id; 141 | const { privacy } = req.body; 142 | if ( 143 | privacy !== 'contacts' && 144 | privacy !== 'everyone' && 145 | privacy !== 'nobody' 146 | ) { 147 | return next( 148 | new AppError( 149 | 'Invalid privacy option. Choose contacts, everyone, or nobody.', 150 | 400 151 | ) 152 | ); 153 | } 154 | const user = await User.findByIdAndUpdate( 155 | userId, 156 | { $set: { lastSeenPrivacy: privacy } }, 157 | { new: true, runValidators: true } 158 | ); 159 | if (!user) { 160 | return next(new AppError('User not found', 404)); 161 | } 162 | res.status(200).json({ 163 | status: 'success', 164 | message: 'Last seen privacy updated successfully', 165 | data: {}, 166 | }); 167 | } 168 | ); 169 | export const changeProfilePicturePrivacy = catchAsync( 170 | async (req: any, res: Response, next: NextFunction) => { 171 | const userId = req.user.id; 172 | const { privacy } = req.body; 173 | if ( 174 | privacy !== 'contacts' && 175 | privacy !== 'everyone' && 176 | privacy !== 'nobody' 177 | ) { 178 | return next( 179 | new AppError( 180 | 'Invalid privacy option. Choose contacts, everyone, or nobody.', 181 | 400 182 | ) 183 | ); 184 | } 185 | const user = await User.findByIdAndUpdate( 186 | userId, 187 | { $set: { picturePrivacy: privacy } }, 188 | { new: true, runValidators: true } 189 | ); 190 | if (!user) { 191 | return next(new AppError('User not found', 404)); 192 | } 193 | res.status(200).json({ 194 | status: 'success', 195 | message: 'Profile picture privacy updated successfully', 196 | data: {}, 197 | }); 198 | } 199 | ); 200 | export const changeInvitePermessionsePrivacy = catchAsync( 201 | async (req: any, res: Response, next: NextFunction) => { 202 | const userId = req.user.id; 203 | const invitePermission = req.body.privacy; 204 | 205 | const user = await User.findByIdAndUpdate( 206 | userId, 207 | { $set: { invitePermessionsPrivacy: invitePermission } }, 208 | { new: true, runValidators: true } 209 | ); 210 | if (!user) { 211 | return next(new AppError('User not found', 404)); 212 | } 213 | res.status(200).json({ 214 | status: 'success', 215 | message: 'Invite permissions privacy updated successfully', 216 | data: {}, 217 | }); 218 | } 219 | ); 220 | -------------------------------------------------------------------------------- /src/controllers/searchController.ts: -------------------------------------------------------------------------------- 1 | import { Response, NextFunction } from 'express'; 2 | import Message from '@base/models/messageModel'; 3 | import Chat from '@base/models/chatModel'; 4 | import catchAsync from '@utils/catchAsync'; 5 | import GroupChannel from '@base/models/groupChannelModel'; 6 | import User from '@base/models/userModel'; 7 | import IUser from '@base/types/user'; 8 | import IGroupChannel from '@base/types/groupChannel'; 9 | 10 | export const searchMessages = catchAsync( 11 | async (req: any, res: Response, next: NextFunction) => { 12 | const globalSearchResult: { 13 | groups: IGroupChannel[]; 14 | users: IUser[]; 15 | channels: IGroupChannel[]; 16 | } = { 17 | groups: [], 18 | users: [], 19 | channels: [], 20 | }; 21 | 22 | const { query, searchSpace, filter, isGlobalSearch } = req.body; 23 | 24 | // Input validation 25 | if (!query || !searchSpace || typeof isGlobalSearch === 'undefined') { 26 | return res 27 | .status(400) 28 | .json({ 29 | message: 'Query, searchSpace, and isGlobalSearch are required', 30 | }); 31 | } 32 | 33 | const searchConditions: any = { content: { $regex: query, $options: 'i' } }; 34 | 35 | // Handle contentType filter 36 | if (filter) { 37 | const filterTypes = filter.split(','); 38 | searchConditions.contentType = { $in: filterTypes }; 39 | } 40 | 41 | // Prepare chat type filters 42 | const spaces = searchSpace.split(','); 43 | const chatTypeConditions: any[] = []; 44 | 45 | if (spaces.includes('chats')) { 46 | chatTypeConditions.push({ type: 'private' }); 47 | } 48 | if (spaces.includes('channels')) { 49 | chatTypeConditions.push({ type: 'channel' }); 50 | } 51 | if (spaces.includes('groups')) { 52 | chatTypeConditions.push({ type: 'group' }); 53 | } 54 | 55 | // Limit search to user's chats unless global search 56 | let chatFilter: any = {}; 57 | const userChats = await Chat.find({ 58 | members: { $elemMatch: { user: req.user._id } }, 59 | }).select('_id type'); 60 | 61 | // Filter user chats by type 62 | const filteredChats = userChats.filter((chat) => 63 | chatTypeConditions.length > 0 64 | ? chatTypeConditions.some((cond) => cond.type === chat.type) 65 | : true 66 | ); 67 | 68 | const chatIds = filteredChats.map((chat) => chat._id); 69 | chatFilter = { chatId: { $in: chatIds } }; 70 | 71 | // Combine filters 72 | const finalSearchConditions = { ...searchConditions, ...chatFilter }; 73 | 74 | // Fetch messages and populate references 75 | const messages = await Message.find(finalSearchConditions) 76 | .populate('senderId', 'username') 77 | .populate({ 78 | path: 'chatId', 79 | select: 'name type', 80 | }) 81 | .limit(50); 82 | 83 | const groups: string[] = []; 84 | messages.forEach((message: any) => { 85 | groups.push(message.chatId.name); 86 | console.log(message.chatId.name); 87 | }); 88 | 89 | // Search for group channels by name in the groups array 90 | const _groupChannels = await GroupChannel.find({ 91 | name: { $in: groups }, 92 | }).select('name type picture'); 93 | 94 | // Now, populate the chatId with name, type, and picture 95 | const updatedMessages = await Message.find(finalSearchConditions) 96 | .populate({ 97 | path: 'chatId', 98 | select: 'name type picture', 99 | match: { name: { $in: groups } }, // Ensure the chatId matches the groups array 100 | }) 101 | .limit(50); 102 | 103 | // This will print the 'name' from the chatId 104 | // Global Search for Groups, Channels, and Chats 105 | if (isGlobalSearch) { 106 | // Groups and Channels by name 107 | const groupsAndChannels = await GroupChannel.find({ 108 | name: { $regex: query, $options: 'i' }, 109 | }).select('name type picture'); 110 | 111 | globalSearchResult.groups = groupsAndChannels.filter( 112 | (gc: IGroupChannel) => gc.type === 'group' 113 | ); 114 | globalSearchResult.channels = groupsAndChannels.filter( 115 | (gc: IGroupChannel) => gc.type === 'channel' 116 | ); 117 | 118 | // Users (to find chats involving usernames) 119 | const users = await User.find({ 120 | $or: [ 121 | { screenFirstName: { $regex: query, $options: 'i' } }, 122 | { screenLastName: { $regex: query, $options: 'i' } }, 123 | { username: { $regex: query, $options: 'i' } }, 124 | ], 125 | }).select( 126 | 'name username _id screenFirstName screenLastName phoneNumber photo bio accountStatus stories' 127 | ); 128 | 129 | globalSearchResult.users = users; 130 | 131 | // Chats where the user is a member and the username matches 132 | // const userIds = users.map((user) => user._id); 133 | // const chats = await Chat.find({ 134 | // members: { $elemMatch: { user: { $in: userIds } } }, 135 | // type: 'private', 136 | // }).select('type members'); 137 | 138 | // globalSearchResult.chats.push(...chats); 139 | } 140 | 141 | res.status(200).json({ 142 | success: true, 143 | data: { 144 | searchResult: updatedMessages, 145 | globalSearchResult, 146 | }, 147 | }); 148 | } 149 | ); 150 | 151 | export const searchMessagesDummmy = catchAsync( 152 | async (req: any, res: Response, next: NextFunction) => { 153 | try { 154 | const { query, searchSpace, isGlobalSearch } = req.query; 155 | 156 | if (!query || !searchSpace || typeof isGlobalSearch === 'undefined') { 157 | return res 158 | .status(400) 159 | .json({ 160 | message: 'Query, searchSpace, and isGlobalSearch are required', 161 | }); 162 | } 163 | } catch (error) { 164 | console.error('Error in searchMessages:', error); 165 | res 166 | .status(500) 167 | .json({ success: false, message: 'Internal Server Error' }); 168 | } 169 | } 170 | ); 171 | -------------------------------------------------------------------------------- /src/controllers/storyController.ts: -------------------------------------------------------------------------------- 1 | import AppError from '@base/errors/AppError'; 2 | import Story from '@base/models/storyModel'; 3 | import User from '@base/models/userModel'; 4 | import { 5 | deleteStoryFile, 6 | deleteStoryInUser, 7 | getUserContacts, 8 | getUsersStoriesData, 9 | } from '@base/services/storyService'; 10 | import catchAsync from '@base/utils/catchAsync'; 11 | import { Response } from 'express'; 12 | import mongoose from 'mongoose'; 13 | 14 | export const getCurrentUserStory = catchAsync( 15 | async (req: any, res: Response) => { 16 | const userId = req.user.id; 17 | 18 | const user = await User.findById(userId).populate('stories'); 19 | 20 | if (!user) { 21 | throw new AppError('No User exists with this ID', 404); 22 | } 23 | 24 | return res.status(200).json({ 25 | status: 'success', 26 | message: 'Stories retrieved successfuly', 27 | data: { 28 | stories: user.stories, 29 | }, 30 | }); 31 | } 32 | ); 33 | export const postStory = catchAsync(async (req: any, res: Response) => { 34 | const { caption } = req.body; 35 | const userId = req.user.id; 36 | 37 | if (!req.file) { 38 | throw new AppError('An error occured while uploading the story', 500); 39 | } 40 | 41 | const newStory = new Story({ 42 | content: req.file?.filename, 43 | caption, 44 | views: [], 45 | }); 46 | 47 | await newStory.save(); 48 | 49 | const user = await User.findByIdAndUpdate( 50 | userId, 51 | { $push: { stories: newStory._id } }, 52 | { new: true, runValidators: true } 53 | ); 54 | 55 | if (!user) { 56 | throw new AppError('No User exists with this ID', 404); 57 | } 58 | 59 | return res.status(201).json({ 60 | status: 'success', 61 | message: 'Story created successfuly', 62 | data: {}, 63 | }); 64 | }); 65 | export const deleteStory = catchAsync(async (req: any, res: Response) => { 66 | const { storyId } = req.params; 67 | const userId = req.user.id; 68 | const storyObjectId = new mongoose.Types.ObjectId(storyId); 69 | 70 | // Delete the story from the user stories 71 | await deleteStoryInUser(storyObjectId, userId); 72 | 73 | // Delete the story file in the server 74 | await deleteStoryFile(storyObjectId); 75 | 76 | // Delete the story object from the database. 77 | await Story.deleteOne({ _id: storyObjectId }); 78 | 79 | return res.status(204).json({ 80 | status: 'success', 81 | message: 'Story deleted successfuly', 82 | data: {}, 83 | }); 84 | }); 85 | 86 | export const getStory = catchAsync(async (req: any, res: Response) => { 87 | const { userId } = req.params; 88 | const authUserId = req.user.id; 89 | 90 | const user = await User.findById(userId).populate( 91 | 'stories', 92 | 'id content caption timestamp' 93 | ); 94 | 95 | if (!user) { 96 | throw new AppError('No User exists with this ID', 404); 97 | } 98 | 99 | if (!user.contacts.includes(authUserId)) { 100 | throw new AppError('You are not authorized to view these stories', 401); 101 | } 102 | 103 | return res.status(200).json({ 104 | status: 'success', 105 | message: 'Stories retrieved successfuly', 106 | data: { 107 | stories: user.stories, 108 | }, 109 | }); 110 | }); 111 | export const viewStory = catchAsync(async (req: any, res: Response) => { 112 | const { storyId } = req.params; 113 | const userId = req.user.id; 114 | 115 | await Story.findByIdAndUpdate( 116 | storyId, 117 | { $addToSet: { views: userId } }, 118 | { new: true, runValidators: true } 119 | ); 120 | 121 | res.status(200).json({ 122 | status: 'success', 123 | message: 'User viewed the story successfuly', 124 | data: {}, 125 | }); 126 | }); 127 | 128 | export const getAllContactsStories = catchAsync( 129 | async (req: any, res: Response) => { 130 | const userId = req.user.id; 131 | 132 | // retrieve authenticated user contacts (users that there is a chat with them), returns a Set 133 | const contactsIds = await getUserContacts(userId); 134 | 135 | // get all contacts stories with also some of their data too (like id, username and profile picture). 136 | const data = await getUsersStoriesData([...contactsIds]); 137 | 138 | res.status(200).json({ 139 | status: 'success', 140 | message: 'Stories retrieved successfuly', 141 | data, 142 | }); 143 | } 144 | ); 145 | -------------------------------------------------------------------------------- /src/database/migrations/20241021134336-init-database.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | async up(db, _client) { 3 | await db.collection('users').updateMany({}, { $set: { phoneNumber: '' } }); 4 | }, 5 | 6 | async down(db, _client) { 7 | await db.collection('users').updateMany({}, { $unset: { fieldName: '' } }); 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /src/database/seed/json/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "email": "admin@gmail.com", 4 | "username": "Hamdy", 5 | "screenFirstName": "Ahmed", 6 | "screenLastName": "Hamdy", 7 | "phoneNumber": "+201063360716", 8 | "password": "12345678", 9 | "passwordConfirm": "12345678", 10 | "accountStatus": "active", 11 | "isAdmin": true 12 | }, 13 | { 14 | "email": "front1@gmail.com", 15 | "username": "Batman", 16 | "screenFirstName": "Bruce", 17 | "screenLastName": "Wayne", 18 | "phoneNumber": "+201051046611", 19 | "password": "12345678", 20 | "passwordConfirm": "12345678", 21 | "accountStatus": "active" 22 | }, 23 | { 24 | "email": "front2@gmail.com", 25 | "username": "Spiderman", 26 | "screenFirstName": "Peter", 27 | "screenLastName": "Parker", 28 | "phoneNumber": "+201055445511", 29 | "password": "12345678", 30 | "passwordConfirm": "12345678", 31 | "accountStatus": "active" 32 | }, 33 | { 34 | "email": "front3@gmail.com", 35 | "username": "Superman", 36 | "screenFirstName": "Clark", 37 | "screenLastName": "Kent", 38 | "phoneNumber": "+201055287611", 39 | "password": "12345678", 40 | "passwordConfirm": "12345678", 41 | "accountStatus": "active" 42 | }, 43 | { 44 | "email": "front4@gmail.com", 45 | "username": "Ironman", 46 | "screenFirstName": "Tony", 47 | "screenLastName": "Stark", 48 | "phoneNumber": "+201050007611", 49 | "password": "12345678", 50 | "passwordConfirm": "12345678", 51 | "accountStatus": "active" 52 | }, 53 | { 54 | "email": "front5@gmail.com", 55 | "username": "Captain_America", 56 | "screenFirstName": "Steve", 57 | "screenLastName": "Rogers", 58 | "phoneNumber": "+201055289991", 59 | "password": "12345678", 60 | "passwordConfirm": "12345678", 61 | "accountStatus": "active" 62 | }, 63 | { 64 | "email": "cross1@gmail.com", 65 | "username": "Incredible_Hulk", 66 | "screenFirstName": "Bruce", 67 | "screenLastName": "Banner", 68 | "phoneNumber": "+201077446611", 69 | "password": "12345678", 70 | "passwordConfirm": "12345678", 71 | "accountStatus": "active" 72 | }, 73 | { 74 | "email": "cross2@gmail.com", 75 | "username": "Black_Widow", 76 | "screenFirstName": "Natasha", 77 | "screenLastName": "Romanoff", 78 | "phoneNumber": "+201055446690", 79 | "password": "12345678", 80 | "passwordConfirm": "12345678", 81 | "accountStatus": "active" 82 | }, 83 | { 84 | "email": "cross3@gmail.com", 85 | "username": "The_Villain", 86 | "screenFirstName": "Thanos", 87 | "screenLastName": "The-Mad-Titan", 88 | "phoneNumber": "+201055446690", 89 | "password": "12345678", 90 | "passwordConfirm": "12345678", 91 | "accountStatus": "active" 92 | }, 93 | { 94 | "email": "cross4@gmail.com", 95 | "username": "Red_Skull", 96 | "screenFirstName": "Johann", 97 | "screenLastName": "Schmidt", 98 | "phoneNumber": "+201012346690", 99 | "password": "12345678", 100 | "passwordConfirm": "12345678", 101 | "accountStatus": "active" 102 | }, 103 | { 104 | "email": "cross5@gmail.com", 105 | "username": "Ugly_Loki", 106 | "screenFirstName": "Loki", 107 | "screenLastName": "Laufeyson", 108 | "phoneNumber": "+201055987690", 109 | "password": "12345678", 110 | "passwordConfirm": "12345678", 111 | "accountStatus": "active" 112 | }, 113 | { 114 | "email": "banned@gmail.com", 115 | "username": "Forever_Banned", 116 | "screenFirstName": "Lonely", 117 | "screenLastName": "Soul", 118 | "phoneNumber": "+201055900090", 119 | "password": "12345678", 120 | "passwordConfirm": "12345678", 121 | "accountStatus": "banned" 122 | }, 123 | { 124 | "email": "deactivated@gmail.com", 125 | "username": "I_HOPE_I_RETURN", 126 | "screenFirstName": "Stolen", 127 | "screenLastName": "Soul", 128 | "phoneNumber": "+201055900090", 129 | "password": "12345678", 130 | "passwordConfirm": "12345678", 131 | "accountStatus": "deactivated" 132 | } 133 | ] 134 | -------------------------------------------------------------------------------- /src/database/seed/seed.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import dotenv from 'dotenv'; 3 | import importData from './userSeed'; 4 | 5 | dotenv.config(); 6 | // eslint-disable-next-line import/first, import/order 7 | import mongoDBConnection from '@config/mongoDB'; 8 | 9 | const seed = async () => { 10 | try { 11 | console.log('🌱 Seeding Database....'); 12 | await importData(); 13 | console.log('Done seeding database successfully!'); 14 | } catch (err) { 15 | console.log(`Failed to seed database :(`); 16 | console.log(err); 17 | } 18 | }; 19 | 20 | const start = async (wouldImport: boolean = false) => { 21 | try { 22 | console.log('⚠️ Dropping Database....'); 23 | await mongoDBConnection(); 24 | if (mongoose.connection.db === undefined) throw new Error(); 25 | await mongoose.connection.db.dropDatabase(); 26 | console.log(`Done dropping database successfully!`); 27 | if (wouldImport) await seed(); 28 | } catch (err) { 29 | console.log(`Failed to drop database :(`); 30 | console.log(err); 31 | } finally { 32 | mongoose.disconnect(); 33 | process.exit(); 34 | } 35 | }; 36 | 37 | if (!['--import', '--delete'].includes(process.argv[2])) { 38 | console.log('You should pass --import or --delete as an argument'); 39 | process.exit(); 40 | } 41 | 42 | start(process.argv[2] === '--import'); 43 | -------------------------------------------------------------------------------- /src/database/seed/userSeed.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { faker } from '@faker-js/faker'; 3 | import User from '@models/userModel'; 4 | import Message from '@models/messageModel'; 5 | import GroupChannel from '@models/groupChannelModel'; 6 | import NormalChat from '@models/normalChatModel'; 7 | import { decryptKey, encryptMessage } from '@utils/encryption'; 8 | 9 | const existingUsers = JSON.parse( 10 | fs.readFileSync(`${__dirname}/json/users.json`, 'utf-8') 11 | ); 12 | 13 | const createRandomUser = () => { 14 | const password = faker.internet.password({ length: 12, memorable: true }); 15 | 16 | return { 17 | email: faker.internet.email(), 18 | username: faker.internet 19 | .username() 20 | .replace(/[.\-/\\]/g, '') 21 | .padEnd(2, '_') 22 | .padStart(2, '_') 23 | .substring(0, 15), 24 | screenFirstName: faker.person.firstName(), 25 | screenLastName: faker.person.lastName(), 26 | phoneNumber: faker.phone.number({ style: 'international' }), 27 | password, 28 | passwordConfirm: password, 29 | accountStatus: 'active', 30 | }; 31 | }; 32 | 33 | const fakerUsers: any = faker.helpers.multiple(createRandomUser, { count: 10 }); 34 | 35 | const createRandomMessage = async (chat: any) => { 36 | const sender: any = faker.helpers.arrayElement(chat.members); 37 | const patterns = [ 38 | `Hey, ${faker.person.firstName()}! How's it going?`, 39 | `Just finished ${faker.word.verb()}ing, feeling great!`, 40 | `Do you know any good places for ${faker.food.dish()} around here?`, 41 | `I'm so ${faker.word.adjective()} about the ${faker.lorem.words(2)} tomorrow!`, 42 | `You should really try ${faker.food.dish()}. It's amazing!`, 43 | `Are you free this weekend for ${faker.word.verb()}ing?`, 44 | `Sounds great! Let me know what time works for you.`, 45 | `Wait, what does '${faker.word.noun()}' mean again?`, 46 | `I was just thinking about ${faker.lorem.sentence()}.`, 47 | `That's exactly what I was going to say!`, 48 | `Sorry, I totally forgot about ${faker.lorem.words(2)}. My bad!`, 49 | `Wow, your ${faker.commerce.product()} looks amazing today!`, 50 | `What time works best for you on ${faker.date.weekday()}?`, 51 | `I can't believe ${faker.lorem.sentence()} happened today!`, 52 | `Did you know that ${faker.lorem.sentence()}?`, 53 | `Why don’t we try ${faker.word.verb()}ing next weekend?`, 54 | `Why did the ${faker.animal.type()} cross the road? To get to the other side!`, 55 | `How’s your work on ${faker.company.name()} coming along?`, 56 | `Let’s meet at ${faker.date.weekday()} at ${faker.location.city()}, sounds good?`, 57 | `Thanks so much for the ${faker.commerce.productName()}, it really made my day!`, 58 | ]; 59 | const content = faker.helpers.arrayElement(patterns); 60 | const message = { 61 | content, 62 | senderId: sender.user, 63 | chatId: chat._id, 64 | isPinned: faker.datatype.boolean(), 65 | isForward: faker.datatype.boolean(), 66 | isEdited: faker.datatype.boolean(), 67 | timestamp: faker.date.recent({ days: 30 }), 68 | }; 69 | 70 | if (chat.encryptionKey) { 71 | message.content = encryptMessage( 72 | message.content, 73 | decryptKey(chat.encryptionKey, chat.keyAuthTag), 74 | decryptKey(chat.initializationVector, chat.vectorAuthTag) 75 | ); 76 | } 77 | 78 | return Message.create(message); 79 | }; 80 | 81 | const generateGroupName = () => { 82 | const adjective = faker.word.adjective(); 83 | const noun = faker.word.noun(); 84 | return `${adjective} ${noun}`; 85 | }; 86 | 87 | const createPublicChat = async ( 88 | users: any[], 89 | takeAll?: boolean, 90 | cType?: String 91 | ) => { 92 | let members; 93 | let chatType; 94 | 95 | if (takeAll) { 96 | members = users; 97 | chatType = cType; 98 | } else { 99 | members = faker.helpers.arrayElements( 100 | users, 101 | faker.number.int({ min: 1, max: users.length }) 102 | ); 103 | chatType = faker.helpers.arrayElement(['group', 'channel']); 104 | } 105 | 106 | const chat = await GroupChannel.create({ 107 | members: members.map((user: any) => ({ 108 | user: user.user, 109 | Role: faker.helpers.arrayElement(['member', 'admin']), 110 | })), 111 | name: generateGroupName(), 112 | type: chatType, 113 | }); 114 | 115 | await Promise.all( 116 | users.map((user: any) => 117 | User.findByIdAndUpdate(user.user, { 118 | $push: { chats: { chat: chat._id } }, 119 | }) 120 | ) 121 | ); 122 | 123 | await Promise.all( 124 | Array.from({ length: 100 }).map(() => createRandomMessage(chat)) 125 | ); 126 | }; 127 | 128 | const createPrivateChat = async (users: any[]) => { 129 | const chat = await NormalChat.create({ 130 | members: users, 131 | }); 132 | 133 | await Promise.all( 134 | users.map((user: any) => 135 | User.findByIdAndUpdate(user.user, { 136 | $push: { chats: { chat: chat._id } }, 137 | }) 138 | ) 139 | ); 140 | 141 | await Promise.all( 142 | Array.from({ length: 100 }).map(() => createRandomMessage(chat)) 143 | ); 144 | }; 145 | 146 | const importData = async () => { 147 | try { 148 | const knownUsers = ((await User.create(existingUsers)) as any).map( 149 | (user: any) => ({ 150 | user: user._id, 151 | }) 152 | ); 153 | 154 | const randomUsers = ((await User.create(fakerUsers)) as any).map( 155 | (user: any) => ({ 156 | user: user._id, 157 | }) 158 | ); 159 | 160 | // Create private chats between every pair of known users 161 | await Promise.all( 162 | knownUsers.map(async (user: any, index: number) => { 163 | for (let i = index + 1; i < knownUsers.length; i += 1) { 164 | createPrivateChat([user, knownUsers[i]]); 165 | } 166 | }) 167 | ); 168 | 169 | // Create Groups and Channels between known users 170 | await Promise.all([ 171 | Array.from({ length: 5 }).map(() => createPublicChat(knownUsers)), 172 | createPublicChat(knownUsers, true, 'channel'), 173 | createPublicChat(knownUsers, true, 'group'), 174 | ]); 175 | 176 | // Create random chats between random users 177 | await Promise.all([ 178 | [ 179 | Array.from({ length: 5 }).map(() => 180 | createPublicChat([...knownUsers, ...randomUsers]) 181 | ), 182 | createPublicChat([...knownUsers, ...randomUsers], true, 'channel'), 183 | createPublicChat([...knownUsers, ...randomUsers], true, 'group'), 184 | ], 185 | ]); 186 | } catch (err) { 187 | console.error('Failed to seed user and chat data:'); 188 | console.error(err instanceof Error ? err.message : err); 189 | } 190 | }; 191 | 192 | export default importData; 193 | -------------------------------------------------------------------------------- /src/errors/AppError.ts: -------------------------------------------------------------------------------- 1 | export default class AppError extends Error { 2 | statusCode: number; 3 | status: string; 4 | isOperational: boolean; 5 | 6 | constructor(message: string, statusCode: number) { 7 | super(message); 8 | 9 | this.statusCode = statusCode; 10 | this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; 11 | this.isOperational = true; 12 | 13 | Error.captureStackTrace(this, this.constructor); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/errors/errorHandlers.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express'; 2 | import AppError from './AppError'; 3 | 4 | export const sendDevError = (err: AppError, res: Response) => { 5 | res.status(err.statusCode).json({ 6 | status: err.status, 7 | name: err.name, 8 | message: err.message, 9 | error: err, 10 | stack: err.stack, 11 | }); 12 | }; 13 | 14 | export const sendProdError = (err: AppError, res: Response) => { 15 | if (err.isOperational) { 16 | res.status(err.statusCode).json({ 17 | status: err.status, 18 | message: err.message, 19 | }); 20 | } else { 21 | res.status(500).json({ 22 | status: 'error', 23 | message: 'Something went wrong :(', 24 | }); 25 | } 26 | }; 27 | 28 | export const handleDuplicateKeysError = (err: Error): AppError => 29 | new AppError(err.message, 409); 30 | 31 | export const handleInvalidPrivacyOption = (err: AppError) => { 32 | err.message = 'Invalid Privacy Option.'; 33 | return new AppError(err.message, 400); 34 | }; 35 | export const handleInvalidAuth = (err: AppError) => { 36 | err.message = 'You are not an Admin.'; 37 | return new AppError(err.message, 400); 38 | }; 39 | -------------------------------------------------------------------------------- /src/errors/globalErrorHandler.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import AppError from './AppError'; 3 | import { 4 | handleDuplicateKeysError, 5 | sendDevError, 6 | sendProdError, 7 | handleInvalidPrivacyOption, 8 | handleInvalidAuth 9 | } from './errorHandlers'; 10 | 11 | const globalErrorHandler = ( 12 | err: AppError, 13 | req: Request, 14 | res: Response, 15 | next: NextFunction 16 | ) => { 17 | err.statusCode = err.statusCode || 500; 18 | err.status = err.status || 'error'; 19 | console.log(err.message); 20 | 21 | if (process.env.NODE_ENV === 'development') { 22 | sendDevError(err, res); 23 | } else if (process.env.NODE_ENV === 'production') { 24 | if ( 25 | err.message === 26 | 'Validation failed: invitePermessionsPrivacy: `nobody` is not a valid enum value for path `invitePermessionsPrivacy`.' 27 | ) 28 | err = handleInvalidPrivacyOption(err); 29 | if ( 30 | err.message === 31 | "You are not authorized to access this resource" 32 | ) 33 | err = handleInvalidAuth(err); 34 | 35 | if (err.name === 'ValidationError') err = handleDuplicateKeysError(err); 36 | 37 | sendProdError(err, res); 38 | } 39 | if ( 40 | err.message === 41 | "You are not authorized to access this resource" 42 | ) 43 | err = handleInvalidAuth(err); 44 | 45 | }; 46 | 47 | export default globalErrorHandler; 48 | -------------------------------------------------------------------------------- /src/errors/uncaughtExceptionHandler.ts: -------------------------------------------------------------------------------- 1 | process.on('uncaughtException', (err: Error) => { 2 | console.log('UNCAUGHT EXCEPTION!!'); 3 | console.error(err.name, err.message); 4 | }); 5 | -------------------------------------------------------------------------------- /src/errors/unhandledRejectionHandler.ts: -------------------------------------------------------------------------------- 1 | process.on('unhandledRejection', (err: Error) => { 2 | console.log('UNHANDLED REJECTION!!'); 3 | console.error(err.name, err.message); 4 | }); 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import '@errors/uncaughtExceptionHandler'; 2 | import '@base/server'; 3 | import '@errors/unhandledRejectionHandler'; 4 | -------------------------------------------------------------------------------- /src/middlewares/authMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import catchAsync from '@utils/catchAsync'; 3 | import AppError from '@errors/AppError'; 4 | import User from '@models/userModel'; 5 | import { reloadSession } from '@services/sessionService'; 6 | import redisClient from '@base/config/redis'; 7 | import IUser from '@base/types/user'; 8 | 9 | export const protect = catchAsync( 10 | async (req: Request, res: Response, next: NextFunction) => { 11 | await reloadSession(req); 12 | if (!req.session.user) { 13 | return next( 14 | new AppError('Session not found, you are not allowed here!', 401) 15 | ); 16 | } 17 | 18 | const currentUser = await User.findById(req.session.user.id).select( 19 | '+password' 20 | ); 21 | if (!currentUser) { 22 | return next( 23 | new AppError('User has been deleted!! You can not log in', 401) 24 | ); 25 | } 26 | 27 | if (currentUser.passwordChanged(req.session.user.timestamp)) { 28 | return next( 29 | new AppError('User has changed password!! Log in again.', 401) 30 | ); 31 | } 32 | req.session.user.lastSeenTime = Date.now(); 33 | req.session.save(); 34 | req.user = currentUser; 35 | next(); 36 | } 37 | ); 38 | 39 | export const savePlatformInfo = catchAsync( 40 | async (req: Request, res: Response, next: NextFunction) => { 41 | const platform = (req.query.platform as string) || 'web'; 42 | await redisClient.set('platform', platform); 43 | next(); 44 | } 45 | ); 46 | 47 | export const isAdmin = catchAsync( 48 | async (req: Request, res: Response, next: NextFunction) => { 49 | const currentUser = req.user as IUser; 50 | if (!currentUser || !currentUser.isAdmin) { 51 | return next(new AppError('You are not authorized to access this resource', 403)); 52 | } 53 | next(); 54 | } 55 | ); 56 | export const isActive = catchAsync( 57 | async (req: Request, res: Response, next: NextFunction) => { 58 | const currentUser = req.user as IUser; 59 | if (!currentUser || currentUser.accountStatus !== 'active') { 60 | return next(new AppError('You are not active', 403)); 61 | } 62 | next(); 63 | } 64 | ); 65 | -------------------------------------------------------------------------------- /src/middlewares/chatMiddlewares.ts: -------------------------------------------------------------------------------- 1 | import AppError from '@base/errors/AppError'; 2 | import Chat from '@base/models/chatModel'; 3 | import IUser from '@base/types/user'; 4 | import catchAsync from '@base/utils/catchAsync'; 5 | import { NextFunction, Request, Response } from 'express'; 6 | import mongoose from 'mongoose'; 7 | 8 | const restrictTo = (...roles: string[]) => 9 | catchAsync(async (req: Request, res: Response, next: NextFunction) => { 10 | const { chatId } = req.params; 11 | const user: IUser = req.user as IUser; 12 | const userId: any = user._id; 13 | const chat = await Chat.findById(chatId); 14 | if (!chatId || !mongoose.Types.ObjectId.isValid(chatId)) 15 | return next(new AppError('please provide a valid chat ID', 400)); 16 | if (!chat) 17 | return next(new AppError('this chat does no longer exists', 400)); 18 | const userChats = user.chats; 19 | if ( 20 | !userChats.some((userChat) => 21 | userChat.chat.equals(new mongoose.Types.ObjectId(chatId)) 22 | ) 23 | ) 24 | return next( 25 | new AppError( 26 | 'you are not a member of this chat, you are not allowed here', 27 | 403 28 | ) 29 | ); 30 | 31 | const chatMembers = chat.members; 32 | const member = chatMembers.find((m: any) => m.user.equals(userId)); 33 | 34 | if ( 35 | member && 36 | chat.type !== 'private' && 37 | roles.length !== 0 && 38 | !roles.includes(member.Role) 39 | ) 40 | return next(new AppError('you do not have permission', 403)); 41 | next(); 42 | }); 43 | 44 | export default restrictTo; 45 | -------------------------------------------------------------------------------- /src/models/chatModel.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import IChat from '@base/types/chat'; 3 | import { decryptKey } from '@base/utils/encryption'; 4 | 5 | const chatSchema = new mongoose.Schema( 6 | { 7 | isSeen: { 8 | type: Boolean, 9 | default: true, 10 | }, 11 | members: [ 12 | { 13 | user: { type: mongoose.Types.ObjectId, ref: 'User' }, 14 | Role: { 15 | type: String, 16 | enum: ['member', 'admin'], 17 | default: 'member', 18 | }, 19 | }, 20 | ], 21 | type: { 22 | type: String, 23 | enum: ['private', 'group', 'channel'], 24 | default: 'private', 25 | }, 26 | isDeleted: { 27 | type: Boolean, 28 | default: false, 29 | }, 30 | }, 31 | { 32 | discriminatorKey: 'chatType', 33 | collection: 'Chat', 34 | toJSON: { 35 | virtuals: true, 36 | transform(doc, ret) { 37 | delete ret.__v; 38 | delete ret.chatType; 39 | if (ret.members) { 40 | ret.members.forEach((member: any) => { 41 | delete member.id; 42 | delete member._id; 43 | }); 44 | } 45 | if (ret.encryptionKey) { 46 | ret.encryptionKey = decryptKey(ret.encryptionKey, ret.keyAuthTag); 47 | ret.initializationVector = decryptKey( 48 | ret.initializationVector, 49 | ret.vectorAuthTag 50 | ); 51 | delete ret.keyAuthTag; 52 | delete ret.vectorAuthTag; 53 | } 54 | return ret; 55 | }, 56 | }, 57 | toObject: { virtuals: true }, 58 | } 59 | ); 60 | 61 | chatSchema.virtual('numberOfMembers').get(function () { 62 | return Array.isArray(this.members) ? this.members.length : 0; 63 | }); 64 | 65 | chatSchema.pre('save', function (next) { 66 | if (!this.isModified('members')) return next(); 67 | const uniqueUsers = new Set(this.members.map((m) => m.user.toString())); 68 | if (uniqueUsers.size !== this.members.length) { 69 | return next(new Error('Members must have unique users.')); 70 | } 71 | next(); 72 | }); 73 | 74 | const Chat = mongoose.model('Chat', chatSchema); 75 | export default Chat; 76 | -------------------------------------------------------------------------------- /src/models/communicationModel.ts: -------------------------------------------------------------------------------- 1 | import ICommunication from '@base/types/communication'; 2 | import mongoose from 'mongoose'; 3 | 4 | const communicationSchema = new mongoose.Schema( 5 | { 6 | timestamp: { 7 | type: Date, 8 | default: Date.now, 9 | }, 10 | senderId: { 11 | type: mongoose.Schema.Types.ObjectId, 12 | required: [true, 'message must have senderId'], 13 | ref: 'User', 14 | }, 15 | chatId: { 16 | type: mongoose.Schema.Types.ObjectId, 17 | required: [true, 'message must have chatId'], 18 | ref: 'Chat', 19 | }, 20 | }, 21 | { 22 | discriminatorKey: 'communicationType', 23 | collection: 'Communication', 24 | toJSON: { 25 | virtuals: true, 26 | transform(doc, ret) { 27 | delete ret.__v; 28 | delete ret.communicationType; 29 | return ret; 30 | }, 31 | }, 32 | toObject: { virtuals: true }, 33 | } 34 | ); 35 | 36 | communicationSchema.index({ timestamp: -1 }, { unique: true, background: true }); 37 | 38 | const Communication = mongoose.model('Communication', communicationSchema); 39 | export default Communication; 40 | -------------------------------------------------------------------------------- /src/models/groupChannelModel.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import IGroupChannel from '@base/types/groupChannel'; 3 | import Chat from './chatModel'; 4 | 5 | const groupChannelSchema = new mongoose.Schema({ 6 | name: { 7 | type: String, 8 | required: [true, 'chat must have a name'], 9 | }, 10 | messagingPermission: { 11 | type: Boolean, 12 | default: true, 13 | }, 14 | downloadingPermission: { 15 | type: Boolean, 16 | default: true, 17 | }, 18 | privacy: { 19 | type: Boolean, 20 | default: true, 21 | }, 22 | createdAt: { 23 | type: Date, 24 | default: Date.now, 25 | }, 26 | isFilterd: { 27 | type: Boolean, 28 | default: false, 29 | }, 30 | picture: { 31 | type: String, 32 | default: '', 33 | }, 34 | }); 35 | 36 | const GroupChannel = Chat.discriminator('GroupChannel', groupChannelSchema); 37 | export default GroupChannel; 38 | -------------------------------------------------------------------------------- /src/models/inviteModel.ts: -------------------------------------------------------------------------------- 1 | import invite from '@base/types/invite'; 2 | import mongoose from 'mongoose'; 3 | 4 | const inviteSchema = new mongoose.Schema({ 5 | token: { 6 | type: String, 7 | required: true, 8 | unique: true, 9 | }, 10 | chatId: { 11 | type: mongoose.Schema.Types.ObjectId, 12 | ref: 'Chat', 13 | required: true, 14 | }, 15 | expiresIn: { 16 | type: Date, 17 | required: true, 18 | }, 19 | }); 20 | 21 | const Invite = mongoose.model('Invite', inviteSchema); 22 | export default Invite; 23 | -------------------------------------------------------------------------------- /src/models/messageModel.ts: -------------------------------------------------------------------------------- 1 | import IMessage from '@base/types/message'; 2 | import mongoose from 'mongoose'; 3 | import Communication from './communicationModel'; 4 | 5 | const messageSchema = new mongoose.Schema({ 6 | content: String, 7 | media: String, 8 | mediaName: String, 9 | mediaSize: Number, 10 | contentType: { 11 | type: String, 12 | enum: ['text', 'image', 'GIF', 'sticker', 'audio', 'video', 'file', 'link'], 13 | default: 'text', 14 | }, 15 | isPinned: { 16 | type: Boolean, 17 | default: false, 18 | }, 19 | isAppropriate: { 20 | type: Boolean, 21 | default: true, 22 | }, 23 | isForward: { 24 | type: Boolean, 25 | default: false, 26 | }, 27 | isEdited: { 28 | type: Boolean, 29 | default: false, 30 | }, 31 | isAnnouncement: { 32 | type: Boolean, 33 | default: false, 34 | }, 35 | deliveredTo: [ 36 | { 37 | type: mongoose.Types.ObjectId, 38 | ref: 'User', 39 | default: [], 40 | }, 41 | ], 42 | readBy: [ 43 | { 44 | type: mongoose.Types.ObjectId, 45 | ref: 'User', 46 | default: [], 47 | }, 48 | ], 49 | parentMessageId: mongoose.Types.ObjectId, 50 | threadMessages: [ 51 | { 52 | type: mongoose.Types.ObjectId, 53 | default: [], 54 | }, 55 | ], 56 | }); 57 | 58 | const Message = Communication.discriminator('Message', messageSchema); 59 | export default Message; 60 | -------------------------------------------------------------------------------- /src/models/normalChatModel.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import crypto from 'crypto'; 3 | import { encryptKey } from '@utils/encryption'; 4 | import INormalChat from '@base/types/normalChat'; 5 | import Chat from './chatModel'; 6 | 7 | const normalChatSchema = new mongoose.Schema({ 8 | encryptionKey: { 9 | type: String, 10 | default: crypto.randomBytes(32).toString('hex'), 11 | }, 12 | initializationVector: { 13 | type: String, 14 | default: crypto.randomBytes(16).toString('hex'), 15 | }, 16 | keyAuthTag: { 17 | type: String, 18 | default: '', 19 | }, 20 | vectorAuthTag: { 21 | type: String, 22 | default: '', 23 | }, 24 | destructionTimestamp: Date, 25 | destructionDuration: Number, 26 | }); 27 | 28 | normalChatSchema.pre('save', function (next) { 29 | if (!this.isNew) return next(); 30 | const { encrypted: encryptedKey, authTag: keyAuthTag } = encryptKey( 31 | this.encryptionKey 32 | ); 33 | const { encrypted: encryptedVector, authTag: vectorAuthTag } = encryptKey( 34 | this.initializationVector 35 | ); 36 | this.encryptionKey = encryptedKey; 37 | this.keyAuthTag = keyAuthTag; 38 | this.initializationVector = encryptedVector; 39 | this.vectorAuthTag = vectorAuthTag; 40 | next(); 41 | }); 42 | 43 | const NormalChat = Chat.discriminator('NormalChat', normalChatSchema); 44 | export default NormalChat; 45 | -------------------------------------------------------------------------------- /src/models/storyModel.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import IStory from '@base/types/story'; 3 | 4 | const storySchema = new mongoose.Schema( 5 | { 6 | content: { 7 | type: String, 8 | required: [true, 'story must have content'], 9 | }, 10 | caption: { 11 | type: String, 12 | default: '', 13 | }, 14 | timestamp: { 15 | type: Date, 16 | default: Date.now, 17 | }, 18 | views: [ 19 | { 20 | type: mongoose.Types.ObjectId, 21 | ref: 'User', 22 | }, 23 | ], 24 | }, 25 | { 26 | toJSON: { 27 | virtuals: true, 28 | transform(doc, ret) { 29 | delete ret.__v; 30 | return ret; 31 | }, 32 | }, 33 | toObject: { virtuals: true }, 34 | } 35 | ); 36 | 37 | const Story = mongoose.model('Story', storySchema); 38 | export default Story; 39 | -------------------------------------------------------------------------------- /src/models/userModel.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import validator from 'validator'; 3 | import bcrypt from 'bcrypt'; 4 | import IUser from '@base/types/user'; 5 | import generateConfirmationCode from '@utils/generateConfirmationCode'; 6 | import crypto from 'crypto'; 7 | 8 | const userSchema = new mongoose.Schema( 9 | { 10 | provider: { 11 | type: String, 12 | enum: ['local', 'google', 'github'], 13 | default: 'local', 14 | }, 15 | providerId: { 16 | type: String, 17 | unique: true, 18 | }, 19 | username: { 20 | type: String, 21 | required: [true, 'Username is required'], 22 | unique: true, 23 | minlength: [5, 'Username is at least 5 characters'], 24 | maxlength: [15, 'Username is at most 15 characters'], 25 | validate: { 26 | validator(username: string): boolean { 27 | const regex = /^[A-Za-z0-9_]+$/; 28 | return regex.test(username); 29 | }, 30 | message: 'Username can contain only letters, numbers and underscore', 31 | }, 32 | }, 33 | fcmToken: { 34 | type: String, 35 | default: '', 36 | }, 37 | screenFirstName: { 38 | type: String, 39 | default: '', 40 | }, 41 | screenLastName: { 42 | type: String, 43 | default: '', 44 | }, 45 | email: { 46 | type: String, 47 | validate: [ 48 | { 49 | validator(email: string): boolean { 50 | return validator.isEmail(email); 51 | }, 52 | message: 'please provide a valid email', 53 | }, 54 | { 55 | async validator(email: string): Promise { 56 | if (this.provider === 'local') { 57 | const existingUser = await mongoose.models.User.find({ 58 | email, 59 | }); 60 | if ( 61 | !existingUser || 62 | existingUser.length === 0 || 63 | (existingUser.length === 1 && 64 | existingUser[0]._id.equals(this._id)) 65 | ) 66 | return true; 67 | return false; 68 | } 69 | return true; 70 | }, 71 | message: 'Email already exists', 72 | }, 73 | ], 74 | lowercase: true, 75 | }, 76 | phoneNumber: { 77 | type: String, 78 | validate: [ 79 | { 80 | validator(phoneNumber: string): boolean { 81 | return validator.isMobilePhone(phoneNumber); 82 | }, 83 | message: 'please provide a valid phone number', 84 | }, 85 | { 86 | async validator(phoneNumber: string): Promise { 87 | if (this.provider === 'local') { 88 | const existingUser = await mongoose.models.User.find({ 89 | phoneNumber, 90 | }); 91 | if ( 92 | !existingUser || 93 | existingUser.length === 0 || 94 | (existingUser.length === 1 && 95 | existingUser[0]._id.equals(this._id)) 96 | ) 97 | return true; 98 | return false; 99 | } 100 | return true; 101 | }, 102 | message: 'Phone number already exists', 103 | }, 104 | ], 105 | }, 106 | password: { 107 | type: String, 108 | required: [true, 'A password is required'], 109 | maxLength: [15, 'max length is 15 characters'], 110 | minLength: [8, 'min length is 8 characters'], 111 | select: false, 112 | }, 113 | passwordConfirm: { 114 | type: String, 115 | required: [true, 'confirm your password'], 116 | select: false, 117 | validate: { 118 | validator(passwordConfirm: String): boolean { 119 | return passwordConfirm === this.password; 120 | }, 121 | message: 'passwords are not the same', 122 | }, 123 | }, 124 | photo: { 125 | type: String, 126 | default: '', 127 | }, 128 | status: { 129 | type: String, 130 | enum: ['online', 'connected', 'offline'], 131 | default: 'offline', 132 | }, 133 | isAdmin: { 134 | type: Boolean, 135 | default: false, 136 | }, 137 | bio: { 138 | type: String, 139 | maxlength: [70, 'Bio is at most 70 characters'], 140 | default: '', 141 | }, 142 | accountStatus: { 143 | type: String, 144 | enum: ['active', 'unverified', 'deactivated', 'banned'], 145 | default: 'unverified', 146 | }, 147 | maxFileSize: { 148 | type: Number, 149 | default: 3145, 150 | }, 151 | automaticDownloadEnable: { 152 | type: Boolean, 153 | default: true, 154 | }, 155 | lastSeenPrivacy: { 156 | type: String, 157 | enum: ['everyone', 'contacts', 'nobody'], 158 | default: 'everyone', 159 | }, 160 | readReceiptsEnablePrivacy: { 161 | type: Boolean, 162 | default: true, 163 | }, 164 | storiesPrivacy: { 165 | type: String, 166 | enum: ['everyone', 'contacts', 'nobody'], 167 | default: 'everyone', 168 | }, 169 | picturePrivacy: { 170 | type: String, 171 | enum: ['everyone', 'contacts', 'nobody'], 172 | default: 'everyone', 173 | }, 174 | invitePermessionsPrivacy: { 175 | type: String, 176 | enum: ['everyone', 'admins'], 177 | default: 'everyone', 178 | }, 179 | stories: [ 180 | { 181 | type: mongoose.Types.ObjectId, 182 | ref: 'Story', 183 | }, 184 | ], 185 | blockedUsers: [ 186 | { 187 | type: mongoose.Types.ObjectId, 188 | ref: 'User', 189 | }, 190 | ], 191 | contacts: [ 192 | { 193 | type: mongoose.Types.ObjectId, 194 | ref: 'User', 195 | }, 196 | ], 197 | chats: [ 198 | { 199 | chat: { 200 | type: mongoose.Types.ObjectId, 201 | ref: 'Chat', 202 | }, 203 | isMuted: { 204 | type: Boolean, 205 | default: false, 206 | }, 207 | muteDuration: Number, 208 | draft: { 209 | type: String, 210 | default: '', 211 | }, 212 | }, 213 | ], 214 | changedPasswordAt: { type: Date, select: false }, 215 | emailVerificationCode: { type: String, select: false }, 216 | emailVerificationCodeExpires: { type: Number, select: false }, 217 | verificationAttempts: { type: Number, select: false, default: 0 }, 218 | resetPasswordToken: { type: String, select: false }, 219 | resetPasswordExpires: { type: String, select: false }, 220 | }, 221 | { 222 | toJSON: { 223 | virtuals: true, 224 | transform(doc, ret) { 225 | delete ret.__v; 226 | if (ret.chats) { 227 | ret.chats.forEach((chat: any) => { 228 | delete chat.id; 229 | delete chat._id; 230 | }); 231 | } 232 | if (ret.username) return ret; 233 | return ret.chats; 234 | }, 235 | }, 236 | toObject: { virtuals: true }, 237 | } 238 | ); 239 | 240 | userSchema.index({ email: 1 }, { background: true }); 241 | 242 | userSchema.pre('save', async function(next) { 243 | if (!this.isModified('password') || !this.password) return next(); 244 | this.password = await bcrypt.hash(this.password, 12); 245 | this.passwordConfirm = undefined; 246 | next(); 247 | }); 248 | 249 | userSchema.pre('save', function(next) { 250 | if (this.provider === 'local') { 251 | this.providerId = this._id as string; 252 | } 253 | next(); 254 | }); 255 | 256 | userSchema.methods.isCorrectPassword = async function( 257 | candidatePass: string 258 | ): Promise { 259 | const result = await bcrypt.compare(candidatePass, this.password); 260 | if (result) this.matchedPasswords = true; 261 | return result; 262 | }; 263 | 264 | userSchema.methods.passwordChanged = function(tokenIssuedAt: number): boolean { 265 | if ( 266 | this.changedPasswordAt && 267 | this.changedPasswordAt.getTime() / 1000 > tokenIssuedAt 268 | ) 269 | return true; 270 | return false; 271 | }; 272 | 273 | userSchema.methods.generateSaveConfirmationCode = function(): string { 274 | const confirmationCode: string = generateConfirmationCode(); 275 | this.emailVerificationCode = crypto 276 | .createHash('sha256') 277 | .update(confirmationCode) 278 | .digest('hex'); 279 | this.emailVerificationCodeExpires = 280 | Date.now() + Number(process.env.VERIFICATION_CODE_EXPIRES_IN) * 60 * 1000; 281 | return confirmationCode; 282 | }; 283 | 284 | userSchema.methods.createResetPasswordToken = function(): string { 285 | const resetPasswordToken = crypto.randomBytes(32).toString('hex'); 286 | 287 | this.resetPasswordToken = crypto 288 | .createHash('sha256') 289 | .update(resetPasswordToken) 290 | .digest('hex'); 291 | 292 | this.resetPasswordExpires = 293 | Date.now() + 294 | parseInt(process.env.RESET_TOKEN_EXPIRES_IN as string, 10) * 60 * 1000; 295 | 296 | return resetPasswordToken; 297 | }; 298 | 299 | const User = mongoose.model('User', userSchema); 300 | export default User; 301 | -------------------------------------------------------------------------------- /src/models/voiceCallModel.ts: -------------------------------------------------------------------------------- 1 | import IVoiceCall from '@base/types/voiceCall'; 2 | import mongoose from 'mongoose'; 3 | import Communication from './communicationModel'; 4 | 5 | const voiceCallSchema = new mongoose.Schema({ 6 | timestamp: { type: Date, default: Date.now }, 7 | duration: { type: Number, default: -1 }, 8 | callType: { type: String, enum: ['group', 'private'], required: true }, 9 | status: { type: String, enum: ['ongoing', 'finished'], default: 'ongoing' }, 10 | senderId: { 11 | type: mongoose.Schema.Types.ObjectId, 12 | ref: 'User', 13 | required: true, 14 | }, 15 | currentParticipants: [ 16 | { type: mongoose.Types.ObjectId, ref: 'User', default: [] }, 17 | ], 18 | chatId: { type: mongoose.Schema.Types.ObjectId, ref: 'Chat', required: true }, 19 | }); 20 | 21 | const VoiceCall = Communication.discriminator('VoiceCall', voiceCallSchema); 22 | export default VoiceCall; 23 | -------------------------------------------------------------------------------- /src/public/media/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TelwareSW/telware-backend/4bc3129f1e28bbfc02b57516d8a47a3ec4550257/src/public/media/.gitkeep -------------------------------------------------------------------------------- /src/routes/apiRoute.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import authRouter from '@routes/authRoute'; 3 | import userRouter from '@routes/userRoute'; 4 | import storyRouter from '@base/routes/storyRoute'; 5 | import chatRouter from '@base/routes/chatRoute'; 6 | import searchRouter from '@base/routes/searchRoute'; 7 | 8 | const apiRouter = Router(); 9 | 10 | apiRouter.use('/auth', authRouter); 11 | apiRouter.use('/users', userRouter); 12 | apiRouter.use('/stories', storyRouter); 13 | apiRouter.use('/chats', chatRouter); 14 | apiRouter.use('/search', searchRouter); 15 | 16 | export default apiRouter; 17 | -------------------------------------------------------------------------------- /src/routes/authRoute.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { 3 | signup, 4 | sendConfirmationCode, 5 | verifyEmail, 6 | login, 7 | forgotPassword, 8 | resetPassword, 9 | logoutOthers, 10 | logoutAll, 11 | changePassword, 12 | logoutSession, 13 | getLogedInSessions, 14 | getCurrentSession, 15 | } from '@controllers/authController'; 16 | import { protect , isActive } from '@middlewares/authMiddleware'; 17 | import oauthRouter from '@base/routes/oauthRoute'; 18 | 19 | const router = Router(); 20 | 21 | router.use('/oauth', oauthRouter); 22 | 23 | router.post('/signup', signup); 24 | 25 | router.post('/login', login); 26 | router.post('/send-confirmation', sendConfirmationCode); 27 | router.post('/verify', verifyEmail); 28 | router.post('/password/forget', forgotPassword); 29 | router.patch('/password/reset/:token', resetPassword); 30 | 31 | router.use(protect); 32 | router.patch('/password/change', protect, changePassword); 33 | 34 | router.use(isActive); 35 | router.get('/me', getCurrentSession); 36 | router.get('/sessions', getLogedInSessions); 37 | router.post('/logout', logoutSession); 38 | router.post('/logout/all', logoutAll); 39 | router.post('/logout/others', logoutOthers); 40 | 41 | export default router; 42 | -------------------------------------------------------------------------------- /src/routes/chatRoute.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { 3 | getAllChats, 4 | getMessages, 5 | postMediaFile, 6 | getChat, 7 | setPrivacy, 8 | getChatMembers, 9 | updateChatPicture, 10 | invite, 11 | join, 12 | getVoiceCallsInChat, 13 | filterChatGroups, 14 | unfilterChatGroups, 15 | } from '@base/controllers/chatController'; 16 | import { protect, isAdmin } from '@base/middlewares/authMiddleware'; 17 | import upload from '@base/config/fileUploads'; 18 | import restrictTo from '@base/middlewares/chatMiddlewares'; 19 | 20 | const router = Router(); 21 | 22 | router.use(protect); 23 | router.get('/', getAllChats); 24 | router.post('/media', upload.single('file'), postMediaFile); 25 | router.patch( 26 | '/picture/:chatId', 27 | restrictTo(), 28 | upload.single('file'), 29 | updateChatPicture 30 | ); 31 | 32 | router.patch('/privacy/:chatId', restrictTo('admin'), setPrivacy); 33 | 34 | router.get('/invite/:chatId', restrictTo('admin'), invite); 35 | router.post('/join/:token', join); 36 | 37 | router.get('/voice-calls/:chatId', restrictTo(), getVoiceCallsInChat); 38 | router.get('/messages/:chatId', restrictTo(), getMessages); 39 | router.get('/members/:chatId', restrictTo(), getChatMembers); 40 | router.get('/:chatId', restrictTo(), getChat); 41 | 42 | router.patch('/groups/filter/:chatId', isAdmin, filterChatGroups); 43 | router.patch('/groups/unfilter/:chatId', isAdmin, unfilterChatGroups); 44 | 45 | export default router; 46 | -------------------------------------------------------------------------------- /src/routes/oauthRoute.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import passport from 'passport'; 3 | import { oAuthCallback } from '@controllers/authController'; 4 | import { savePlatformInfo } from '@base/middlewares/authMiddleware'; 5 | 6 | const router = Router(); 7 | router.get( 8 | '/google', 9 | savePlatformInfo, 10 | passport.authenticate('google', { 11 | scope: ['profile', 'email'], 12 | }) 13 | ); 14 | router.get('/google/redirect', passport.authenticate('google'), oAuthCallback); 15 | router.get( 16 | '/github', 17 | savePlatformInfo, 18 | passport.authenticate('github', { scope: ['user:email'] }) 19 | ); 20 | router.get('/github/redirect', passport.authenticate('github'), oAuthCallback); 21 | 22 | export default router; 23 | -------------------------------------------------------------------------------- /src/routes/privacyRoute.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { 3 | switchReadRecieptsState, 4 | changeStoriesPrivacy, 5 | changeLastSeenPrivacy, 6 | changeProfilePicturePrivacy, 7 | changeInvitePermessionsePrivacy, 8 | } from '@controllers/privacyController'; 9 | 10 | const router = Router(); 11 | 12 | router.patch('/read-receipts', switchReadRecieptsState); 13 | router.patch('/stories', changeStoriesPrivacy); 14 | router.patch('/last-seen', changeLastSeenPrivacy); 15 | router.patch('/picture', changeProfilePicturePrivacy); 16 | router.patch('/invite-permissions', changeInvitePermessionsePrivacy); 17 | 18 | export default router; 19 | -------------------------------------------------------------------------------- /src/routes/searchRoute.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { 3 | searchMessages, 4 | } from '@controllers/searchController'; 5 | import { protect } from '@base/middlewares/authMiddleware'; 6 | import restrictTo from '@base/middlewares/chatMiddlewares'; 7 | 8 | const router = Router(); 9 | router.use(protect); 10 | router.post('/search-request',searchMessages); 11 | 12 | export default router; 13 | -------------------------------------------------------------------------------- /src/routes/storyRoute.ts: -------------------------------------------------------------------------------- 1 | import { protect } from '@middlewares/authMiddleware'; 2 | import { viewStory } from '@controllers/storyController'; 3 | import { Router } from 'express'; 4 | 5 | const router = Router(); 6 | 7 | router.post('/:storyId/views', protect, viewStory); 8 | 9 | export default router; 10 | -------------------------------------------------------------------------------- /src/routes/userRoute.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import upload from '@base/config/fileUploads'; 3 | import privacyRouter from '@routes/privacyRoute'; 4 | import { 5 | block, 6 | getBlockedUsers, 7 | unblock, 8 | } from '@controllers/privacyController'; 9 | import { 10 | deletePicture, 11 | getAllUsers, 12 | getCheckUserName, 13 | getCurrentUser, 14 | getUser, 15 | updateBio, 16 | updateCurrentUser, 17 | updateEmail, 18 | updatePhoneNumber, 19 | updatePicture, 20 | updateScreenName, 21 | updateUsername, 22 | getAllGroups, 23 | activateUser, 24 | deactivateUser, 25 | banUser, 26 | updateFCMToken, 27 | } from '@controllers/userController'; 28 | import { 29 | deleteStory, 30 | getAllContactsStories, 31 | getCurrentUserStory, 32 | getStory, 33 | postStory, 34 | } from '@controllers/storyController'; 35 | import { protect, isAdmin, isActive } from '@middlewares/authMiddleware'; 36 | 37 | const router = Router(); 38 | 39 | router.use(protect); 40 | router.use(isActive); 41 | router.use('/privacy', privacyRouter); 42 | router.get('/stories', getCurrentUserStory); 43 | router.post('/stories', upload.single('file'), postStory); 44 | router.delete('/stories/:storyId', deleteStory); 45 | 46 | // Block settings 47 | router.get('/block', getBlockedUsers); 48 | router.post('/block/:id', block); 49 | router.delete('/block/:id', unblock); 50 | 51 | // Admin routes 52 | router.patch('/activate/:userId', isAdmin, activateUser); 53 | router.patch('/deactivate/:userId', isAdmin, deactivateUser); 54 | router.patch('/ban/:userId', isAdmin, banUser); 55 | router.get('/all-groups', isAdmin, getAllGroups); 56 | 57 | // User routes 58 | router.get('/me', getCurrentUser); 59 | router.get('/username/check', getCheckUserName); 60 | router.patch('/me', updateCurrentUser); 61 | router.patch('/bio', updateBio); 62 | router.patch('/phone', updatePhoneNumber); 63 | router.patch('/email', updateEmail); 64 | router.patch('/username', updateUsername); 65 | router.patch('/screen-name', updateScreenName); 66 | router.patch('/picture', upload.single('file'), updatePicture); 67 | router.patch('/fcm-token', updateFCMToken); 68 | router.delete('/picture', deletePicture); 69 | router.get('/contacts/stories', getAllContactsStories); 70 | router.get('/:userId/stories', getStory); 71 | router.get('/:userId', getUser); 72 | router.get('/', getAllUsers); 73 | 74 | export default router; 75 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import '@config/env'; 3 | import '@config/passport'; 4 | import '@config/firebase'; 5 | import mongoDBConnection from '@config/mongoDB'; 6 | import app from '@base/app'; 7 | import socketSetup from './sockets/socket'; 8 | 9 | mongoDBConnection(); 10 | 11 | const httpServer = http.createServer(app); 12 | 13 | socketSetup(httpServer); 14 | 15 | const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; 16 | const server = httpServer.listen(port, '0.0.0.0', () => { 17 | console.log(`Server is running on port ${port}`); 18 | }); 19 | 20 | export default server; 21 | -------------------------------------------------------------------------------- /src/services/authService.ts: -------------------------------------------------------------------------------- 1 | import { CookieOptions, Response, NextFunction } from 'express'; 2 | import { IReCaptchaResponse } from '@base/types/recaptchaResponse'; 3 | import User from '@models/userModel'; 4 | import IUser from '@base/types/user'; 5 | import crypto from 'crypto'; 6 | import sendEmail from '@utils/email'; 7 | import { 8 | formConfirmationMessage, 9 | formConfirmationMessageHtml, 10 | formResetPasswordMessage, 11 | formResetPasswordMessageHtml, 12 | } from '@utils/emailMessages'; 13 | import AppError from '@errors/AppError'; 14 | import axios from 'axios'; 15 | 16 | export const validateBeforeLogin = async ( 17 | email: string, 18 | password: string 19 | ): Promise => { 20 | if (!email || !password) return 'missing email or password'; 21 | 22 | const user = await User.findOne({ email }).select('+password'); 23 | if (user && user.accountStatus === 'unverified') 24 | return 'please verify your email first to be able to login'; 25 | if (user && !(await user.isCorrectPassword(password))) 26 | return 'wrong email or password'; 27 | 28 | return 'validated'; 29 | }; 30 | 31 | export const generateUsername = async (): Promise => { 32 | let username: string; 33 | 34 | // eslint-disable-next-line no-constant-condition 35 | while (true) { 36 | username = btoa(Math.random().toString(36).substring(2, 17)); 37 | username = username.replace(/[^a-zA-Z0-9]/g, ''); 38 | // eslint-disable-next-line no-await-in-loop 39 | const user = await User.findOne({ username }); 40 | if (!user) return username; 41 | } 42 | }; 43 | 44 | export const storeCookie = ( 45 | res: Response, 46 | COOKIE_EXPIRES_IN: string, 47 | token: string, 48 | cookieName: string 49 | ): void => { 50 | const cookieOptions: CookieOptions = { 51 | expires: new Date(Date.now() + Number(COOKIE_EXPIRES_IN) * 60 * 60 * 1000), 52 | httpOnly: true, 53 | secure: false, 54 | }; 55 | 56 | if (process.env.NODE_ENV === 'production') { 57 | cookieOptions.secure = true; 58 | } 59 | res.cookie(cookieName, token, cookieOptions); 60 | }; 61 | 62 | export const verifyReCaptcha = async ( 63 | recaptchaResponse: string 64 | ): Promise => { 65 | if (!recaptchaResponse) 66 | return { message: 'please validate the recaptcha', response: 400 }; 67 | 68 | const verificationURL: string = `https://www.google.com/recaptcha/api/siteverify?secret=${process.env.RECAPTCHA_SECRET}&response=${recaptchaResponse}`; 69 | const verificationResponse = await axios.post(verificationURL); 70 | const verificationResponseData = verificationResponse.data; 71 | 72 | if (!verificationResponseData.success) 73 | return { message: 'reCaptcha verification failed', response: 400 }; 74 | return { message: 'recaptcha is verified', response: 200 }; 75 | }; 76 | 77 | export const isCorrectVerificationCode = async ( 78 | user: IUser, 79 | verificationCode: string 80 | ): Promise => { 81 | const hashedCode = crypto 82 | .createHash('sha256') 83 | .update(verificationCode) 84 | .digest('hex'); 85 | console.log(user.emailVerificationCode, hashedCode); 86 | if ( 87 | hashedCode !== user.emailVerificationCode || 88 | (user.emailVerificationCodeExpires && 89 | Date.now() > user.emailVerificationCodeExpires) 90 | ) 91 | return false; 92 | return true; 93 | }; 94 | 95 | export const sendConfirmationCodeEmail = async ( 96 | user: IUser, 97 | verificationCode: string 98 | ) => { 99 | const { email } = user; 100 | const message: string = formConfirmationMessage(email, verificationCode); 101 | const htmlMessage: string = formConfirmationMessageHtml( 102 | email, 103 | verificationCode 104 | ); 105 | await sendEmail({ 106 | email, 107 | subject: 'Verify your Email Address for Telware', 108 | message, 109 | htmlMessage, 110 | }); 111 | }; 112 | 113 | export const sendEmailVerificationCode = async ( 114 | user: IUser | undefined, 115 | next: NextFunction, 116 | errorState: any 117 | ) => { 118 | if (!user) 119 | return next( 120 | new AppError( 121 | 'Please register first to be able to verify your email!', 122 | 400 123 | ) 124 | ); 125 | 126 | if (user.accountStatus !== 'unverified') 127 | return next(new AppError('Your account is already verified!', 400)); 128 | 129 | if ( 130 | user.emailVerificationCodeExpires && 131 | Date.now() < user.emailVerificationCodeExpires 132 | ) 133 | return next( 134 | new AppError( 135 | 'A verification email is already sent, you can ask for another after this one expires', 136 | 400 137 | ) 138 | ); 139 | const verificationCode = user.generateSaveConfirmationCode(); 140 | await user.save({ validateBeforeSave: false }); 141 | await sendConfirmationCodeEmail(user, verificationCode); 142 | errorState.errorCaught = false; 143 | }; 144 | 145 | export const sendResetPasswordEmail = async ( 146 | resetURL: string, 147 | email: string 148 | ) => { 149 | const message: string = formResetPasswordMessage(email, resetURL); 150 | const htmlMessage: string = formResetPasswordMessageHtml(email, resetURL); 151 | await sendEmail({ 152 | email, 153 | subject: 'Reset your Password for Telware', 154 | message, 155 | htmlMessage, 156 | }); 157 | }; 158 | 159 | export const createOAuthUser = async ( 160 | profile: any, 161 | email?: string 162 | ): Promise => { 163 | const user = await User.findOne({ providerId: profile.id }); 164 | if (user) return user; 165 | 166 | if (!email) { 167 | email = profile.emails ? profile.emails[0].value : undefined; 168 | } 169 | const photo = profile.photos ? profile.photos[0].value : undefined; 170 | const username = await generateUsername(); 171 | 172 | const newUser: IUser = new User({ 173 | provider: profile.provider, 174 | providerId: profile.id, 175 | screenName: profile.displayName, 176 | accountStatus: 'active', 177 | email, 178 | photo, 179 | username, 180 | }); 181 | await newUser.save({ validateBeforeSave: false }); 182 | return newUser; 183 | }; 184 | -------------------------------------------------------------------------------- /src/services/chatService.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import NormalChat from '@base/models/normalChatModel'; 3 | import Message from '@base/models/messageModel'; 4 | import { Server, Socket } from 'socket.io'; 5 | import User from '@base/models/userModel'; 6 | import AppError from '@base/errors/AppError'; 7 | import GroupChannel from '@base/models/groupChannelModel'; 8 | import deleteFile from '@base/utils/deleteFile'; 9 | import { informSessions } from '@base/sockets/MessagingServices'; 10 | 11 | export const getLastMessage = async (chats: any) => { 12 | const lastMessages = await Promise.all( 13 | chats.map(async (chat: any) => { 14 | const lastMessage = await Message.findOne({ chatId: chat.chat._id }).sort( 15 | { 16 | timestamp: -1, 17 | } 18 | ); 19 | return { 20 | chatId: chat.chat._id, 21 | lastMessage, 22 | }; 23 | }) 24 | ); 25 | return lastMessages; 26 | }; 27 | 28 | export const getUnreadMessages = async (chats: any, user: any) => { 29 | const mentionRegex = /@[[^]]+](([^)]+))/g; 30 | return Promise.all( 31 | chats.map(async (chat: any) => { 32 | const unreadMessages = await Message.find({ 33 | chatId: chat.chat._id, 34 | senderId: { $ne: user._id }, 35 | readBy: { $nin: [user._id] }, 36 | }); 37 | return { 38 | chatId: chat.chat._id, 39 | unreadMessagesCount: unreadMessages.length, 40 | isMentioned: 41 | unreadMessages.filter((message: any) => 42 | mentionRegex.test(message.content) 43 | ).length > 0, 44 | }; 45 | }) 46 | ); 47 | }; 48 | 49 | export const getChats = async ( 50 | userId: mongoose.Types.ObjectId, 51 | type?: string 52 | ): Promise => { 53 | const userChats = await User.findById(userId) 54 | .select('chats') 55 | .populate({ 56 | path: 'chats.chat', 57 | match: type ? { type } : {}, 58 | }); 59 | if (!userChats) return []; 60 | return userChats.chats.filter((chat) => chat.chat !== null); 61 | }; 62 | 63 | export const getChatIds = async ( 64 | userId: mongoose.Types.ObjectId, 65 | type?: string 66 | ) => { 67 | const chats = await getChats(userId, type); 68 | return chats.map((chat: any) => chat.chat._id); 69 | }; 70 | 71 | export const enableDestruction = async ( 72 | socket: Socket, 73 | message: any, 74 | chatId: any 75 | ) => { 76 | const chat = await NormalChat.findById(chatId); 77 | const messageId = message._id; 78 | if (chat && chat.destructionDuration) { 79 | setTimeout(async () => { 80 | await Message.findByIdAndDelete(messageId); 81 | socket.to(chatId).emit('DELETE_MESSAGE_SERVER', messageId); 82 | }, chat.destructionDuration * 1000); 83 | } 84 | }; 85 | 86 | export const muteUnmuteChat = async ( 87 | io: Server, 88 | userId: string, 89 | chatId: string, 90 | event: string, 91 | muteDuration?: number 92 | ) => { 93 | User.findByIdAndUpdate( 94 | userId, 95 | { 96 | $set: { 97 | 'chats.$[elem].isMuted': muteDuration, 98 | 'chats.$[elem].muteDuration': muteDuration, 99 | }, 100 | }, 101 | { 102 | arrayFilters: [{ 'elem.chat': chatId }], 103 | } 104 | ); 105 | informSessions(io, userId, { chatId }, event); 106 | }; 107 | 108 | export const deleteChatPictureFile = async ( 109 | chatId: mongoose.Types.ObjectId | string 110 | ) => { 111 | const chat = await GroupChannel.findById(chatId); 112 | 113 | if (!chat) { 114 | throw new AppError('No Chat exists with this ID', 404); 115 | } 116 | 117 | const fileName = chat.picture; 118 | await deleteFile(fileName); 119 | }; 120 | -------------------------------------------------------------------------------- /src/services/googleAIService.ts: -------------------------------------------------------------------------------- 1 | const { HfInference } = require('@huggingface/inference'); 2 | 3 | const hf = new HfInference(process.env.HF_API_KEY); 4 | 5 | const modelName = 'unitary/toxic-bert'; 6 | 7 | async function detectInappropriateContent(text: string): Promise { 8 | try { 9 | const response = await hf.textClassification({ 10 | model: modelName, 11 | inputs: text, 12 | }); 13 | 14 | console.log('Model Response:', JSON.stringify(response, null, 2)); 15 | 16 | const relevantLabels = ['toxic', 'obscene', 'insult', 'severe_toxic']; 17 | const threshold = 0.7; 18 | 19 | interface TextClassificationResult { 20 | label: string; 21 | score: number; 22 | } 23 | 24 | const toxicityScore = (response as TextClassificationResult[]) 25 | .filter( 26 | (result) => 27 | relevantLabels.includes(result.label.toLowerCase()) && 28 | result.score > threshold 29 | ) 30 | .reduce((acc, curr) => acc + curr.score, 0); 31 | 32 | console.log(`Total Toxicity Score: ${toxicityScore}`); 33 | 34 | return toxicityScore >= threshold; 35 | } catch (error) { 36 | console.error('Error detecting inappropriate content:', error); 37 | throw new Error('Failed to detect inappropriate content'); 38 | } 39 | } 40 | 41 | export default detectInappropriateContent; 42 | -------------------------------------------------------------------------------- /src/services/sessionService.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { randomUUID } from 'crypto'; 3 | import { ObjectId } from 'mongoose'; 4 | import redisClient from '@config/redis'; 5 | import UAParser from 'ua-parser-js'; 6 | 7 | export const getSocketsByUserId = async (userId: any) => 8 | redisClient.sMembers(`user:${userId}:sockets`); 9 | 10 | export const generateSession = (req: any) => { 11 | const sessionId = req.headers 12 | ? (req.headers['x-session-token'] as string) 13 | : undefined; 14 | return sessionId || randomUUID(); 15 | }; 16 | 17 | export const getSession = (req: Request, sessionId: string) => 18 | new Promise((resolve, reject) => { 19 | req.sessionStore.get(sessionId, (err, session) => { 20 | if (err) return reject(err); 21 | if (!session) 22 | redisClient.sRem(`user:${req.session.user?.id}:sessions`, sessionId); 23 | resolve(session); 24 | }); 25 | }); 26 | 27 | export const reloadSession = (req: any) => 28 | new Promise((resolve, _reject) => { 29 | req.session.reload((_error: any) => { 30 | resolve(undefined); 31 | }); 32 | }); 33 | 34 | export const destroySession = ( 35 | req: Request, 36 | res: Response, 37 | sessionId?: string 38 | ) => 39 | new Promise((resolve, reject) => { 40 | if (!sessionId) 41 | req.session.destroy((error: Error) => { 42 | if (error) return reject(error); 43 | res.clearCookie('connect.sid'); 44 | resolve(undefined); 45 | }); 46 | else { 47 | req.sessionStore.destroy(sessionId, (error: Error) => { 48 | if (error) return reject(error); 49 | res.clearCookie('connect.sid'); 50 | resolve(undefined); 51 | }); 52 | } 53 | }); 54 | 55 | export const regenerateSession = async (req: Request) => 56 | new Promise((resolve, reject) => { 57 | req.session.regenerate((err) => { 58 | if (err) return reject(err); 59 | resolve(undefined); 60 | }); 61 | }); 62 | 63 | export const saveSession = async (id: ObjectId, req: Request) => { 64 | await regenerateSession(req); 65 | const parser = new UAParser(); 66 | parser.setUA(req.header('user-agent') as string); 67 | const browser = parser.getBrowser(); 68 | req.session.user = { 69 | id, 70 | timestamp: Date.now(), 71 | lastSeenTime: Date.now(), 72 | status: 'online', 73 | agent: { 74 | device: parser.getDevice().vendor, 75 | os: parser.getOS().name, 76 | browser: browser.name 77 | ? `${browser.name} ${browser.version || ''}` 78 | : undefined, 79 | }, 80 | }; 81 | await redisClient.sAdd(`user:${id}:sessions`, req.sessionID); 82 | }; 83 | 84 | export const getAllSessionsByUserId = async (userId: ObjectId) => 85 | redisClient.sMembers(`user:${userId}:sessions`); 86 | 87 | export const destroyAllSessionsByUserId = async ( 88 | req: Request, 89 | res: Response 90 | ) => { 91 | const sessionIds = ( 92 | await getAllSessionsByUserId(req.session.user?.id as ObjectId) 93 | ).filter((sessionId) => sessionId !== req.sessionID); 94 | 95 | const promises = sessionIds.map((sessionId) => 96 | destroySession(req, res, sessionId) 97 | ); 98 | 99 | await Promise.all(promises); 100 | await redisClient.del(`user:${req.session.user?.id}:sessions`); 101 | }; 102 | -------------------------------------------------------------------------------- /src/services/storyService.ts: -------------------------------------------------------------------------------- 1 | import AppError from '@base/errors/AppError'; 2 | import Story from '@base/models/storyModel'; 3 | import User from '@base/models/userModel'; 4 | import mongoose from 'mongoose'; 5 | import deleteFile from '@base/utils/deleteFile'; 6 | import Chat from '@base/models/chatModel'; 7 | import IStory from '@base/types/story'; 8 | 9 | interface UserAndStoriesData { 10 | userId: string | mongoose.Types.ObjectId; 11 | name: string; 12 | photo: string | undefined; 13 | stories: IStory[]; 14 | } 15 | 16 | export const deleteStoryInUser = async ( 17 | storyId: mongoose.Types.ObjectId, 18 | userId: mongoose.Types.ObjectId | string 19 | ) => { 20 | const user = await User.findById(userId); 21 | 22 | if (!user) { 23 | throw new AppError('No User exists with this ID', 404); 24 | } 25 | 26 | const storyIndex = user.stories.indexOf(storyId); 27 | if (storyIndex === -1) { 28 | throw new AppError('No story exist with this ID in your stories', 404); 29 | } 30 | 31 | user.stories.splice(storyIndex, 1); 32 | await user.save(); 33 | }; 34 | 35 | export const deleteStoryFile = async (storyId: mongoose.Types.ObjectId) => { 36 | const story = await Story.findById(storyId); 37 | 38 | if (!story) { 39 | throw new AppError('No Story exist with this ID', 404); 40 | } 41 | 42 | const fileName = story.content; 43 | await deleteFile(fileName); 44 | }; 45 | 46 | // Returns all the users ids that the user has a private chat with 47 | export const getUserContacts = async ( 48 | userId: mongoose.Types.ObjectId | string 49 | ) => { 50 | let userIdObj = { user: userId }; 51 | if (typeof userId === 'string') { 52 | userIdObj = { user: new mongoose.Types.ObjectId(userId) }; 53 | } 54 | 55 | // Get the private chats that the user is in 56 | const chats = await Chat.find({ 57 | type: 'private', 58 | members: userIdObj, 59 | }); 60 | 61 | // Get all the users that the user has a private chat with 62 | const contacts: Set = new Set(); 63 | chats.forEach((chat) => { 64 | const { members } = chat; 65 | members.forEach((member) => { 66 | if (member.user.toString() !== userId) 67 | contacts.add(member.user.toString()); 68 | }); 69 | }); 70 | 71 | return contacts; 72 | }; 73 | 74 | export const getUsersStoriesData = async (users: string[]) => { 75 | const data: UserAndStoriesData[] = await Promise.all( 76 | users.map(async (userId) => { 77 | const user = await User.findById(userId).populate( 78 | 'stories', 79 | 'id content caption timestamp' 80 | ); 81 | if (!user) { 82 | throw new AppError('No User exists with this ID', 404); 83 | } 84 | const dataObject: UserAndStoriesData = { 85 | userId, 86 | name: user.username, 87 | photo: user.photo, 88 | stories: user.stories as any, 89 | }; 90 | 91 | return dataObject; 92 | }) 93 | ); 94 | 95 | return data; 96 | }; 97 | -------------------------------------------------------------------------------- /src/services/userService.ts: -------------------------------------------------------------------------------- 1 | import AppError from '@base/errors/AppError'; 2 | import User from '@base/models/userModel'; 3 | import deleteFile from '@base/utils/deleteFile'; 4 | import mongoose from 'mongoose'; 5 | 6 | const deletePictureFile = async (userId: mongoose.Types.ObjectId | string) => { 7 | const user = await User.findById(userId); 8 | 9 | if (!user) { 10 | throw new AppError('No User exists with this ID', 404); 11 | } 12 | 13 | const fileName = user.photo; 14 | await deleteFile(fileName); 15 | }; 16 | export default deletePictureFile; 17 | -------------------------------------------------------------------------------- /src/sockets/MessagingServices.ts: -------------------------------------------------------------------------------- 1 | import { Server, Socket } from 'socket.io'; 2 | import { Types } from 'mongoose'; 3 | import { getChatIds } from '@services/chatService'; 4 | import { getSocketsByUserId } from '@services/sessionService'; 5 | import User from '@models/userModel'; 6 | import GroupChannel from '@models/groupChannelModel'; 7 | import Message from '@models/messageModel'; 8 | import IMessage from '@base/types/message'; 9 | import detectInappropriateContent from '@base/services/googleAIService'; 10 | 11 | export interface Member { 12 | user: Types.ObjectId; 13 | Role: 'member' | 'admin'; 14 | } 15 | 16 | export const check = async ( 17 | chat: any, 18 | ack: Function, 19 | senderId: any, 20 | additionalData?: any 21 | ) => { 22 | const { chatType, checkAdmin, newMessageIsReply, content, sendMessage } = 23 | additionalData; 24 | 25 | if (!chat || chat.isDeleted) { 26 | return ack({ 27 | success: false, 28 | message: 'Chat not found', 29 | }); 30 | } 31 | 32 | const chatMembers = chat.members; 33 | if (chatMembers.length === 0) 34 | return ack({ 35 | success: false, 36 | message: 'this chat is deleted and it no longer exists', 37 | }); 38 | 39 | const sender: Member = chatMembers.find((m: Member) => 40 | m.user.equals(senderId) 41 | ) as unknown as Member; 42 | 43 | if (!sender) 44 | return ack({ 45 | success: false, 46 | message: 'you are not a member of this chat', 47 | }); 48 | 49 | if (chatType && !chatType.includes(chat.type)) 50 | return ack({ 51 | success: false, 52 | message: `this is a ${chat.type} chat!`, 53 | }); 54 | 55 | if (checkAdmin && sender.Role !== 'admin') 56 | return ack({ 57 | success: false, 58 | message: 'you do not have permission as you are not an admin', 59 | }); 60 | 61 | if (sendMessage && chat.type !== 'private') { 62 | const groupChannelChat = await GroupChannel.findById(chat._id); 63 | if ( 64 | chat?.type === 'group' && 65 | chat.isFilterd && 66 | (await detectInappropriateContent(content)) 67 | ) 68 | return 'inappropriate'; 69 | if (sender.Role !== 'admin') { 70 | if (!groupChannelChat.messagingPermission) 71 | return ack({ 72 | success: false, 73 | message: 'only admins can post and reply to this chat', 74 | }); 75 | if (chat.type === 'channel' && !newMessageIsReply) 76 | return ack({ 77 | success: false, 78 | message: 'only admins can post to this channel', 79 | }); 80 | } 81 | } 82 | return 'ok'; 83 | }; 84 | 85 | export const informSessions = async ( 86 | io: Server, 87 | userId: string, 88 | data: any, 89 | event: string 90 | ) => { 91 | let memberSocket; 92 | const socketIds = await getSocketsByUserId(userId); 93 | if (!socketIds || socketIds.length !== 0) 94 | socketIds.forEach((socketId: any) => { 95 | memberSocket = io.sockets.sockets.get(socketId); 96 | if (memberSocket) memberSocket.emit(event, data); 97 | }); 98 | }; 99 | 100 | export const joinRoom = async ( 101 | io: Server, 102 | roomId: String, 103 | userId: Types.ObjectId 104 | ) => { 105 | const socketIds = await getSocketsByUserId(userId); 106 | socketIds.forEach(async (socketId: string) => { 107 | const socket = io.sockets.sockets.get(socketId); 108 | if (socket) socket.join(roomId.toString()); 109 | }); 110 | }; 111 | 112 | export const updateDraft = async ( 113 | io: Server, 114 | senderId: string, 115 | chatId: string, 116 | content: string 117 | ) => { 118 | User.findByIdAndUpdate( 119 | senderId, 120 | { $set: { 'chats.$[chat].draft': content } }, 121 | { 122 | arrayFilters: [{ 'chat.chat': chatId }], 123 | } 124 | ); 125 | informSessions( 126 | io, 127 | senderId, 128 | { chatId, draft: content }, 129 | 'UPDATE_DRAFT_SERVER' 130 | ); 131 | }; 132 | 133 | export const joinAllRooms = async (socket: Socket, userId: Types.ObjectId) => { 134 | const chatIds = await getChatIds(userId); 135 | chatIds.forEach(async (chatId: Types.ObjectId) => { 136 | socket.join(chatId.toString()); 137 | }); 138 | }; 139 | 140 | export const deliverMessages = async ( 141 | io: Server, 142 | socket: Socket, 143 | userId: Types.ObjectId 144 | ) => { 145 | const user = await User.findById(userId); 146 | if (!user) return; 147 | 148 | const messages = await Message.find({ 149 | chatId: { $in: user.chats.map((chat: any) => chat.chat) }, 150 | senderId: { $ne: userId }, 151 | deliveredTo: { $nin: [userId] }, 152 | readBy: { $nin: [userId] }, 153 | }); 154 | 155 | Promise.all( 156 | messages.map(async (message: IMessage) => { 157 | message.deliveredTo.push(userId); 158 | message.save(); 159 | informSessions( 160 | io, 161 | message.senderId.toString(), 162 | message, 163 | 'MESSAGE_DELIVERED' 164 | ); 165 | }) 166 | ); 167 | }; 168 | -------------------------------------------------------------------------------- /src/sockets/messages.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | import { Server, Socket } from 'socket.io'; 3 | import IMessage from '@base/types/message'; 4 | import Message from '@models/messageModel'; 5 | import { enableDestruction } from '@services/chatService'; 6 | import Chat from '@base/models/chatModel'; 7 | import { check, informSessions, updateDraft } from './MessagingServices'; 8 | import handleNotifications from './notifications'; 9 | 10 | interface PinUnPinMessageData { 11 | chatId: string | Types.ObjectId; 12 | messageId: string | Types.ObjectId; 13 | } 14 | 15 | const handleMessaging = async ( 16 | io: any, 17 | socket: Socket, 18 | data: any, 19 | ack: Function, 20 | senderId: string 21 | ) => { 22 | let { media, mediaName, mediaSize, content, contentType, parentMessageId } = 23 | data; 24 | const { chatId, chatType, isReply, isForward, isAnnouncement } = data; 25 | 26 | if ( 27 | (!isForward && 28 | !content && 29 | !media && 30 | (!contentType || !chatType || !chatId)) || 31 | ((isReply || isForward) && !parentMessageId) 32 | ) 33 | return ack({ 34 | success: false, 35 | message: 'Failed to send the message', 36 | error: 'missing required Fields', 37 | }); 38 | 39 | if (isForward && (content || media || contentType)) 40 | return ack({ 41 | success: false, 42 | message: 'Failed to send the message', 43 | error: 'conflicting fields', 44 | }); 45 | 46 | const chat = await Chat.findById(chatId); 47 | const valid = await check(chat, ack, senderId, { 48 | newMessageIsReply: isReply, 49 | content, 50 | sendMessage: true, 51 | }); 52 | if (!valid) return; 53 | 54 | let parentMessage; 55 | if (isForward || isReply) { 56 | parentMessage = (await Message.findById(parentMessageId)) as IMessage; 57 | if (!parentMessage) 58 | return ack({ 59 | success: false, 60 | message: 'Failed to send the message', 61 | error: 'No message found with the provided id', 62 | }); 63 | 64 | if (isForward) { 65 | ({ content, contentType, media, mediaName, mediaSize } = parentMessage); 66 | parentMessageId = undefined; 67 | } 68 | } 69 | 70 | const message = new Message({ 71 | media, 72 | mediaName, 73 | mediaSize, 74 | content, 75 | contentType, 76 | isForward, 77 | senderId, 78 | chatId, 79 | parentMessageId, 80 | isAnnouncement, 81 | isAppropriate: valid === 'ok', 82 | }); 83 | 84 | await message.save(); 85 | 86 | handleNotifications(message.id.toString()); 87 | 88 | if (parentMessage && isReply && chatType === 'channel') { 89 | parentMessage.threadMessages.push(message._id as Types.ObjectId); 90 | await parentMessage.save(); 91 | } 92 | 93 | await updateDraft(io, senderId, chatId, ''); 94 | socket.to(chatId).emit('RECEIVE_MESSAGE', message, async (res: any) => { 95 | if (res.success && res.userId !== senderId) { 96 | if (res.isRead && !message.readBy.includes(res.userId)) { 97 | message.readBy.push(res.userId); 98 | } else if (!message.deliveredTo.includes(res.userId)) { 99 | message.deliveredTo.push(res.userId); 100 | } 101 | message.save(); 102 | informSessions( 103 | io, 104 | senderId, 105 | message, 106 | res.isRead ? 'MESSAGE_READ_SERVER' : 'MESSAGE_DELIVERED' 107 | ); 108 | } 109 | }); 110 | enableDestruction(socket, message, chatId); 111 | ack({ 112 | success: true, 113 | message: 'Message sent successfully', 114 | data: message, 115 | }); 116 | }; 117 | 118 | const handleEditMessage = async (socket: Socket, data: any, ack: Function) => { 119 | const { messageId, content, chatId } = data; 120 | if (!messageId || !content) 121 | return ack({ 122 | success: false, 123 | message: 'Failed to edit the message', 124 | error: 'missing required Fields', 125 | }); 126 | const message = await Message.findByIdAndUpdate( 127 | messageId, 128 | { content, isEdited: true }, 129 | { new: true } 130 | ); 131 | if (!message) 132 | return ack({ 133 | success: false, 134 | message: 'Failed to edit the message', 135 | error: 'no message found with the provided id', 136 | }); 137 | if (message.isForward) 138 | return ack({ 139 | success: false, 140 | message: 'Failed to edit the message', 141 | error: 'cannot edit a forwarded message', 142 | }); 143 | socket.to(chatId).emit('EDIT_MESSAGE_SERVER', message); 144 | ack({ 145 | success: true, 146 | message: 'Message edited successfully', 147 | res: { message }, 148 | }); 149 | }; 150 | 151 | const handleDeleteMessage = async ( 152 | socket: Socket, 153 | data: any, 154 | ack: Function 155 | ) => { 156 | const { messageId, chatId } = data; 157 | if (!messageId) 158 | return ack({ 159 | success: false, 160 | message: 'Failed to delete the message', 161 | error: 'missing required Fields', 162 | }); 163 | const message = await Message.findByIdAndDelete(messageId); 164 | if (!message) 165 | return ack({ 166 | success: false, 167 | message: 'Failed to delete the message', 168 | error: 'no message found with the provided id', 169 | }); 170 | socket.to(chatId).emit('DELETE_MESSAGE_SERVER', message); 171 | ack({ success: true, message: 'Message deleted successfully' }); 172 | }; 173 | 174 | const handleReadMessage = async ( 175 | io: Server, 176 | socket: Socket, 177 | data: any, 178 | ack: Function, 179 | userId: string 180 | ) => { 181 | const { chatId } = data; 182 | const messages = await Message.find({ 183 | chatId, 184 | senderId: { $ne: userId }, 185 | readBy: { $nin: [userId] }, 186 | }); 187 | if (!messages) 188 | return ack({ 189 | success: true, 190 | message: 'No messages to read', 191 | }); 192 | messages.forEach(async (message: IMessage) => { 193 | message.deliveredTo = message.deliveredTo.filter( 194 | (id) => id.toString() !== userId 195 | ); 196 | message.readBy.push(new Types.ObjectId(userId)); 197 | message.save(); 198 | informSessions( 199 | io, 200 | message.senderId.toString(), 201 | message, 202 | 'MESSAGE_READ_SERVER' 203 | ); 204 | }); 205 | ack({ success: true, message: 'Message read successfully' }); 206 | }; 207 | 208 | const handlePinMessage = async ( 209 | socket: Socket, 210 | data: PinUnPinMessageData, 211 | ack: Function 212 | ) => { 213 | const message = await Message.findById(data.messageId); 214 | if (!message) { 215 | return ack({ success: false, message: 'Failed to pin message' }); 216 | } 217 | 218 | message.isPinned = true; 219 | await message.save(); 220 | 221 | socket.to(data.chatId.toString()).emit('PIN_MESSAGE_SERVER', data); 222 | ack({ success: true, message: 'Message pinned successfully' }); 223 | }; 224 | 225 | const handleUnPinMessage = async ( 226 | socket: Socket, 227 | data: PinUnPinMessageData, 228 | ack: Function 229 | ) => { 230 | const message = await Message.findById(data.messageId); 231 | if (!message) { 232 | return ack({ success: false, message: 'Failed to unpin message' }); 233 | } 234 | 235 | message.isPinned = false; 236 | await message.save(); 237 | 238 | socket.to(data.chatId.toString()).emit('UNPIN_MESSAGE_SERVER', data); 239 | ack({ success: true, message: 'Message unpinned successfully' }); 240 | }; 241 | 242 | async function registerMessagesHandlers( 243 | io: Server, 244 | socket: Socket, 245 | userId: string 246 | ) { 247 | socket.on('SEND_MESSAGE', (data: any, ack: Function) => 248 | handleMessaging(io, socket, data, ack, userId) 249 | ); 250 | 251 | socket.on('EDIT_MESSAGE_CLIENT', (data: any, ack: Function) => 252 | handleEditMessage(socket, data, ack) 253 | ); 254 | 255 | socket.on('DELETE_MESSAGE_CLIENT', (data: any, ack: Function) => 256 | handleDeleteMessage(socket, data, ack) 257 | ); 258 | 259 | socket.on('MESSAGE_READ_CLIENT', (data: any, ack: Function) => { 260 | handleReadMessage(io, socket, data, ack, userId); 261 | }); 262 | 263 | socket.on('PIN_MESSAGE_CLIENT', (data: PinUnPinMessageData, ack: Function) => 264 | handlePinMessage(socket, data, ack) 265 | ); 266 | 267 | socket.on( 268 | 'UNPIN_MESSAGE_CLIENT', 269 | (data: PinUnPinMessageData, ack: Function) => 270 | handleUnPinMessage(socket, data, ack) 271 | ); 272 | } 273 | 274 | export default registerMessagesHandlers; 275 | -------------------------------------------------------------------------------- /src/sockets/middlewares.ts: -------------------------------------------------------------------------------- 1 | import redisClient from '@base/config/redis'; 2 | import sessionMiddleware from '@base/config/session'; 3 | import AppError from '@base/errors/AppError'; 4 | import { reloadSession } from '@base/services/sessionService'; 5 | import { Socket } from 'socket.io'; 6 | 7 | export const protectSocket = async (socket: any, next: any) => { 8 | try { 9 | await reloadSession(socket.request); 10 | if (!socket.request.session.user) { 11 | return next( 12 | new AppError('Session not found, you are not allowed here!', 401) 13 | ); 14 | } 15 | await redisClient.sAdd( 16 | `user:${socket.request.session.user.id}:sockets`, 17 | socket.id 18 | ); 19 | socket.request.session.user.lastSeenTime = Date.now(); 20 | socket.request.session.user.status = 'online'; 21 | socket.request.session.save(); 22 | next(); 23 | } catch (error) { 24 | console.log(error); 25 | next(error); 26 | } 27 | }; 28 | 29 | export const authorizeSocket = (socket: Socket, next: any) => { 30 | const sessionToken = 31 | socket.handshake.auth.sessionId ?? socket.handshake.query.sessionId; 32 | const req = socket.request; 33 | 34 | req.headers['x-session-token'] = sessionToken; 35 | sessionMiddleware(req as any, {} as any, next); 36 | }; 37 | -------------------------------------------------------------------------------- /src/sockets/notifications.ts: -------------------------------------------------------------------------------- 1 | import { messaging } from '@base/config/firebase'; 2 | import Chat from '@base/models/chatModel'; 3 | import Message from '@base/models/messageModel'; 4 | import User from '@base/models/userModel'; 5 | 6 | const sendNotification = async (fcmToken: string, title: string, body: any) => { 7 | const message = { 8 | notification: { 9 | title, 10 | body, 11 | }, 12 | token: fcmToken, 13 | }; 14 | 15 | try { 16 | const response = await messaging.send(message); 17 | console.log('Notification sent successfully:', response); 18 | } catch (error) { 19 | console.error('Error sending notification:', error); 20 | } 21 | }; 22 | 23 | const sendNotificationToChat = async (senderId: string, chatId: string) => { 24 | const targetChat = await Chat.findById(chatId).populate('members'); 25 | 26 | if (!targetChat) return; 27 | 28 | const memberIds = targetChat.members.filter( 29 | (memberId) => memberId.toString() !== senderId 30 | ); 31 | 32 | const members = await User.find({ _id: { $in: memberIds } }, 'chats'); 33 | 34 | members.forEach((member) => { 35 | const targetChatInfo = member.chats.find( 36 | ({ chat, isMuted }) => chat.toString() === chatId.toString() && !isMuted 37 | ); 38 | 39 | if (targetChatInfo) { 40 | sendNotification( 41 | member.fcmToken, 42 | 'Message Received', 43 | `Message received from ${member.username}` 44 | ); 45 | } 46 | }); 47 | }; 48 | 49 | const handleNotifications = async (messageId: string) => { 50 | const message = await Message.findById(messageId); 51 | 52 | const { senderId, chatId } = message; 53 | sendNotificationToChat(senderId, chatId); 54 | }; 55 | 56 | export default handleNotifications; 57 | -------------------------------------------------------------------------------- /src/sockets/socket.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'socket.io'; 2 | import { Server as HTTPServer } from 'http'; 3 | import corsOptions from '@base/config/cors'; 4 | import registerChatHandlers from '@base/sockets/chats'; 5 | import redisClient from '@base/config/redis'; 6 | import { Types } from 'mongoose'; 7 | import { deliverMessages, joinAllRooms } from './MessagingServices'; 8 | import registerMessagesHandlers from './messages'; 9 | import { authorizeSocket, protectSocket } from './middlewares'; 10 | import registerVoiceCallHandlers from './voiceCalls'; 11 | 12 | const socketSetup = (server: HTTPServer) => { 13 | const io = new Server(server, { 14 | cors: corsOptions, 15 | }); 16 | 17 | io.use(authorizeSocket); 18 | io.use(protectSocket); 19 | 20 | io.on('connection', async (socket: any) => { 21 | const userId = socket.request.session.user.id as string; 22 | console.log(`New client with userID ${userId} connected: ${socket.id}`); 23 | await joinAllRooms(socket, new Types.ObjectId(userId)); 24 | deliverMessages(io, socket, new Types.ObjectId(userId)); 25 | 26 | socket.on('error', (error: Error) => { 27 | console.error(`Socket error on ${socket.id}:`, error); 28 | 29 | socket.emit('ERROR', { 30 | message: 'An error occurred on the server', 31 | details: error.message, 32 | }); 33 | }); 34 | 35 | socket.on('disconnect', async () => { 36 | console.log(`Client with userID ${userId} disconnected: ${socket.id}`); 37 | socket.request.session.user.lastSeenTime = Date.now(); 38 | socket.request.session.user.status = 'offline'; 39 | socket.request.session.save(); 40 | redisClient.sRem(`user:${userId}:sockets`, socket.id); 41 | }); 42 | 43 | registerChatHandlers(io, socket, userId); 44 | registerMessagesHandlers(io, socket, userId); 45 | registerVoiceCallHandlers(io, socket, userId); 46 | }); 47 | }; 48 | 49 | export default socketSetup; 50 | -------------------------------------------------------------------------------- /src/sockets/voiceCalls.ts: -------------------------------------------------------------------------------- 1 | import { Server, Socket } from 'socket.io'; 2 | import { 3 | addClientToCall, 4 | createVoiceCall, 5 | getClientSocketId, 6 | removeClientFromCall, 7 | } from './voiceCallsServices'; 8 | 9 | interface CreateCallData { 10 | chatId: string; 11 | targetId: string | undefined; 12 | } 13 | 14 | interface JoinLeaveCallData { 15 | voiceCallId: string; 16 | } 17 | 18 | interface SignalData { 19 | type: 'ICE' | 'OFFER' | 'ANSWER'; 20 | targetId: string; 21 | voiceCallId: string; 22 | data: any; 23 | } 24 | 25 | async function handleCreateCall( 26 | io: Server, 27 | socket: Socket, 28 | data: CreateCallData, 29 | userId: string 30 | ) { 31 | const { targetId } = data; 32 | let { chatId } = data; 33 | 34 | if (targetId && !chatId) { 35 | //TODO: Create a new chat between the target and the user. 36 | chatId = '123'; 37 | } 38 | 39 | console.log('User Started Call: ', userId); 40 | const voiceCall = await createVoiceCall(chatId, userId); 41 | 42 | io.to(chatId).emit('CALL-STARTED', { 43 | snederId: userId, 44 | chatId, 45 | voiceCallId: voiceCall._id, 46 | }); 47 | } 48 | 49 | async function handleJoinCall( 50 | io: Server, 51 | socket: Socket, 52 | data: JoinLeaveCallData, 53 | userId: string 54 | ) { 55 | const { voiceCallId } = data; 56 | console.log( 57 | `Client Joined call, clientId: ${userId} , callId: ${voiceCallId}` 58 | ); 59 | await addClientToCall(socket, userId, voiceCallId); 60 | 61 | socket.join(voiceCallId); 62 | 63 | socket.to(voiceCallId).emit('CLIENT-JOINED', { 64 | clientId: userId, 65 | voiceCallId, 66 | }); 67 | } 68 | 69 | async function handleSignal( 70 | io: Server, 71 | socket: Socket, 72 | signalData: SignalData, 73 | userId: string 74 | ) { 75 | const { type, targetId, voiceCallId, data } = signalData; 76 | 77 | console.log( 78 | `Signal Sent, type: ${type}, senderId: ${userId}, targetId: ${targetId}, voiceCallId: ${voiceCallId}` 79 | ); 80 | 81 | const socketId = getClientSocketId(voiceCallId, targetId); 82 | 83 | io.to(socketId).emit('SIGNAL-CLIENT', { 84 | type, 85 | senderId: userId, 86 | voiceCallId, 87 | data, 88 | }); 89 | } 90 | 91 | async function handleLeaveCall( 92 | io: Server, 93 | socket: Socket, 94 | data: JoinLeaveCallData, 95 | userId: string 96 | ) { 97 | const { voiceCallId } = data; 98 | 99 | console.log(`Client Left, clientId: ${userId}, voiceCallId: ${voiceCallId}`); 100 | 101 | socket.leave(voiceCallId); 102 | 103 | await removeClientFromCall(userId, voiceCallId); 104 | 105 | socket.to(voiceCallId).emit('CLIENT-LEFT', { 106 | clientId: userId, 107 | voiceCallId, 108 | }); 109 | } 110 | 111 | async function registerVoiceCallHandlers( 112 | io: Server, 113 | socket: Socket, 114 | userId: string 115 | ) { 116 | socket.on('CREATE-CALL', (data: CreateCallData) => { 117 | handleCreateCall(io, socket, data, userId.toString()); 118 | }); 119 | 120 | socket.on('JOIN-CALL', (data: JoinLeaveCallData) => { 121 | handleJoinCall(io, socket, data, userId.toString()); 122 | }); 123 | 124 | socket.on('SIGNAL-SERVER', (data: SignalData) => { 125 | handleSignal(io, socket, data, userId.toString()); 126 | }); 127 | 128 | socket.on('LEAVE', (data: JoinLeaveCallData) => { 129 | handleLeaveCall(io, socket, data, userId.toString()); 130 | }); 131 | 132 | //TODO: DON'T FORGET TO HANDLE ERRORS (WRAP HANDLERS WITH ANOTHER FUNCTION LIKE catchAsync) 133 | } 134 | 135 | export default registerVoiceCallHandlers; 136 | -------------------------------------------------------------------------------- /src/sockets/voiceCallsServices.ts: -------------------------------------------------------------------------------- 1 | import Chat from '@base/models/chatModel'; 2 | import VoiceCall from '@base/models/voiceCallModel'; 3 | import IVoiceCall from '@base/types/voiceCall'; 4 | import mongoose from 'mongoose'; 5 | import { Socket } from 'socket.io'; 6 | 7 | interface ClientSocketMap { 8 | [voiceCallId: string]: { 9 | [userId: string]: string; 10 | }; 11 | } 12 | 13 | const clientSocketMap: ClientSocketMap = {}; 14 | 15 | async function endVoiceCall(voiceCallId: string) { 16 | // Delete voice call entry in map. 17 | delete clientSocketMap[voiceCallId]; 18 | 19 | const voiceCall: IVoiceCall = await VoiceCall.findById(voiceCallId); 20 | 21 | // Calculate duration in minutes 22 | voiceCall.duration = Math.floor( 23 | (Date.now() - voiceCall.timestamp.getTime()) / (1000 * 60) 24 | ); 25 | 26 | voiceCall.status = 'finished'; 27 | 28 | await voiceCall.save(); 29 | } 30 | 31 | export async function createVoiceCall(chatId: string, userId: string) { 32 | const chat = await Chat.findById(chatId); 33 | 34 | const voiceCall = new VoiceCall({ 35 | callType: chat?.type === 'private' ? 'private' : 'group', 36 | senderId: new mongoose.Types.ObjectId(userId), 37 | chatId: new mongoose.Types.ObjectId(chatId), 38 | }); 39 | 40 | await voiceCall.save(); 41 | 42 | console.log('Voice Call created: ', voiceCall._id); 43 | 44 | return voiceCall; 45 | } 46 | 47 | export async function addClientToCall( 48 | socket: Socket, 49 | userId: string, 50 | voiceCallId: string 51 | ) { 52 | // Add a user Id object into the call current participants. 53 | const voiceCall: IVoiceCall = await VoiceCall.findById(voiceCallId); 54 | const userIdObj = new mongoose.Types.ObjectId(userId); 55 | 56 | //TODO: UNCOMMENT AFTER IMPLEMENTING ERROR HANDLING 57 | /* 58 | if (voiceCall.status === 'finished') { 59 | throw new Error('This voice call has already finished!'); 60 | } 61 | */ 62 | 63 | const userIdIndex = voiceCall.currentParticipants.indexOf(userIdObj); 64 | 65 | if (userIdIndex === -1) { 66 | voiceCall.currentParticipants.push(userIdObj); 67 | } else { 68 | voiceCall.currentParticipants[userIdIndex] = userIdObj; 69 | } 70 | 71 | await voiceCall.save(); 72 | 73 | // Add the client socket id into the map 74 | if (!clientSocketMap[voiceCallId]) clientSocketMap[voiceCallId] = {}; 75 | clientSocketMap[voiceCallId][userId] = socket.id; 76 | console.log('clientSocketMap: ', clientSocketMap[voiceCallId]); 77 | } 78 | 79 | export async function removeClientFromCall( 80 | userId: string, 81 | voiceCallId: string 82 | ) { 83 | // Delete the userId entry from the map 84 | if (clientSocketMap[voiceCallId]) delete clientSocketMap[voiceCallId][userId]; 85 | 86 | // Delete the userId from current participants of voice call. 87 | const voiceCall: IVoiceCall = await VoiceCall.findById(voiceCallId); 88 | const userIdObj = new mongoose.Types.ObjectId(userId); 89 | 90 | const userIdIndex = voiceCall.currentParticipants.indexOf(userIdObj); 91 | 92 | if (userIdIndex !== -1) { 93 | voiceCall.currentParticipants.splice(userIdIndex, 1); 94 | await voiceCall.save(); 95 | } 96 | 97 | if (voiceCall.currentParticipants.length === 0) { 98 | await endVoiceCall(voiceCallId); 99 | } 100 | 101 | console.log('clientSocketMap: ', clientSocketMap[voiceCallId]); 102 | } 103 | 104 | export function getClientSocketMap(): ClientSocketMap { 105 | return clientSocketMap; 106 | } 107 | 108 | export function getClientSocketId(voiceCallId: string, userId: string) { 109 | //TODO: UNCOMMENT AFTER IMPLEMENTING ERROR HANDLING 110 | /* 111 | if (!clientSocketMap[voiceCallId]) 112 | throw new Error('No voice call exists with this id!'); 113 | 114 | if (!clientSocketMap[voiceCallId][userId]) 115 | throw new Error('No socket exists for this user id!'); 116 | */ 117 | return clientSocketMap[voiceCallId][userId]; 118 | } 119 | -------------------------------------------------------------------------------- /src/tests/auth.test.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TelwareSW/telware-backend/4bc3129f1e28bbfc02b57516d8a47a3ec4550257/src/tests/auth.test.ts -------------------------------------------------------------------------------- /src/types/chat.ts: -------------------------------------------------------------------------------- 1 | import { Types, Document } from 'mongoose'; 2 | 3 | interface IChat extends Document { 4 | isSeen: boolean; 5 | members: { user: Types.ObjectId; Role: string }[]; 6 | type: string; 7 | isDeleted: boolean; 8 | } 9 | 10 | export default IChat; 11 | -------------------------------------------------------------------------------- /src/types/communication.ts: -------------------------------------------------------------------------------- 1 | import { Document, Types } from 'mongoose'; 2 | 3 | interface ICommunication extends Document { 4 | timestamp: Date; 5 | senderId: Types.ObjectId; 6 | chatId: Types.ObjectId; 7 | } 8 | 9 | export default ICommunication; 10 | -------------------------------------------------------------------------------- /src/types/groupChannel.ts: -------------------------------------------------------------------------------- 1 | import IChat from './chat'; 2 | 3 | interface IGroupChannel extends IChat { 4 | name: string; 5 | messagingPermission: boolean; 6 | downloadingPermission: boolean; 7 | privacy: boolean; 8 | createdAt: Date; 9 | isFilterd: boolean; 10 | picture: string; 11 | } 12 | 13 | export default IGroupChannel; 14 | -------------------------------------------------------------------------------- /src/types/invite.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongoose'; 2 | 3 | interface invite { 4 | token: string; 5 | expiresIn: Date; 6 | chatId: ObjectId; 7 | } 8 | 9 | export default invite; 10 | -------------------------------------------------------------------------------- /src/types/message.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | import ICommunication from './communication'; 3 | 4 | interface IMessage extends ICommunication { 5 | content: string; 6 | media: string; 7 | mediaName: string; 8 | mediaSize: number; 9 | contentType: string; 10 | isPinned: boolean; 11 | isForward: boolean; 12 | isEdited: boolean; 13 | isAnnouncement: boolean; 14 | deliveredTo: Types.ObjectId[]; 15 | readBy: Types.ObjectId[]; 16 | parentMessageId: Types.ObjectId | undefined; 17 | threadMessages: Types.ObjectId[]; 18 | isAppropriate: boolean; 19 | } 20 | 21 | export default IMessage; 22 | -------------------------------------------------------------------------------- /src/types/normalChat.ts: -------------------------------------------------------------------------------- 1 | import IChat from './chat'; 2 | 3 | interface INormalChat extends IChat { 4 | encryptionKey: String; 5 | initializationVector: String; 6 | keyAuthTag: String; 7 | vectorAuthTag: String; 8 | destructionTimestamp: Date | undefined; 9 | destructionDuration: number | undefined; 10 | } 11 | 12 | export default INormalChat; 13 | -------------------------------------------------------------------------------- /src/types/recaptchaResponse.ts: -------------------------------------------------------------------------------- 1 | export interface IReCaptchaResponse { 2 | message: string; 3 | response: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/story.ts: -------------------------------------------------------------------------------- 1 | import { Document, Types } from 'mongoose'; 2 | 3 | interface IStory extends Document { 4 | content: string; 5 | caption: string; 6 | timestamp: Date; 7 | duration: number; 8 | views: Types.ObjectId; 9 | } 10 | 11 | export default IStory; 12 | -------------------------------------------------------------------------------- /src/types/user.ts: -------------------------------------------------------------------------------- 1 | import { Document, Types } from 'mongoose'; 2 | 3 | interface IUser extends Document { 4 | provider: string; 5 | providerId: string; 6 | username: string; 7 | screenFirstName: string; 8 | screenLastName: string; 9 | email: string; 10 | fcmToken: string; 11 | phoneNumber: string | undefined; 12 | password: string | undefined; 13 | passwordConfirm: string | undefined; 14 | photo: string | undefined; 15 | status: string; 16 | isAdmin: boolean; 17 | bio: string; 18 | accountStatus: string; 19 | maxFileSize: number; 20 | automaticDownloadEnable: boolean; 21 | lastSeenPrivacy: string; 22 | readReceiptsEnablePrivacy: boolean; 23 | storiesPrivacy: string; 24 | picturePrivacy: string; 25 | invitePermessionsPrivacy: string; 26 | stories: Types.ObjectId[]; 27 | blockedUsers: Types.ObjectId[]; 28 | contacts: Types.ObjectId[]; 29 | chats: { 30 | chat: Types.ObjectId; 31 | isMuted: boolean; 32 | muteDuration: number; 33 | draft: string; 34 | }[]; 35 | changedPasswordAt: Date | undefined; 36 | emailVerificationCode: string | undefined; 37 | emailVerificationCodeExpires: number | undefined; 38 | verificationAttempts: number | undefined; 39 | resetPasswordToken: string | undefined; 40 | resetPasswordExpires: number | undefined; 41 | messages: Types.ObjectId[]; 42 | 43 | generateSaveConfirmationCode: () => string; 44 | isCorrectPassword: (_candidatePass: string) => Promise; 45 | passwordChanged: (_tokenIssuedAt: number) => boolean; 46 | createResetPasswordToken: () => string; 47 | } 48 | 49 | export default IUser; 50 | -------------------------------------------------------------------------------- /src/types/voiceCall.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | import ICommunication from './communication'; 3 | 4 | interface IVoiceCall extends ICommunication { 5 | callType: String; 6 | currentParticipants: Types.ObjectId[]; 7 | duration: Number; 8 | status: String; 9 | } 10 | 11 | export default IVoiceCall; 12 | -------------------------------------------------------------------------------- /src/utils/catchAsync.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | 3 | const catchAsync = 4 | (fn: any) => (req: Request, res: Response, next: NextFunction) => { 5 | fn(req, res, next).catch(next); 6 | }; 7 | 8 | export default catchAsync; 9 | -------------------------------------------------------------------------------- /src/utils/deleteFile.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import path from 'path'; 3 | 4 | const deleteFile = async (fileName: string | undefined) => { 5 | if (!fileName || !fileName.trim()) return; 6 | 7 | const filePath = path.join(process.cwd(), 'src/public/media/', fileName); 8 | 9 | try { 10 | // Check if the file exists 11 | await fs.access(filePath); 12 | // Delete the file if it exists 13 | await fs.unlink(filePath); 14 | } catch (err: any) { 15 | // Ignore file not found errors (ENOENT) 16 | if (err.code !== 'ENOENT') { 17 | throw err; // Rethrow unexpected errors 18 | } 19 | } 20 | }; 21 | 22 | export default deleteFile; 23 | -------------------------------------------------------------------------------- /src/utils/email.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from 'nodemailer'; 2 | import { MailOptions } from 'nodemailer/lib/json-transport'; 3 | 4 | const telwareTeam: string = 'Telware '; 5 | 6 | const createTransporter = (provider?: string) => { 7 | if (provider === 'gmail') 8 | return nodemailer.createTransport({ 9 | service: 'gmail', 10 | auth: { 11 | user: process.env.TELWARE_EMAIL, 12 | pass: process.env.TELWARE_PASSWORD, 13 | }, 14 | }); 15 | 16 | return nodemailer.createTransport({ 17 | host: process.env.MAILTRAP_HOST, 18 | port: Number(process.env.MAIL_PORT), 19 | auth: { 20 | user: process.env.MAILTRAP_USERNAME, 21 | pass: process.env.MAILTRAP_PASSWORD, 22 | }, 23 | }); 24 | }; 25 | 26 | const sendEmail = async (options: any) => { 27 | const transporter = createTransporter(process.env.EMAIL_PROVIDER); 28 | 29 | const mailOptions: MailOptions = { 30 | from: telwareTeam, 31 | to: options.email, 32 | subject: options.subject, 33 | text: options.message, 34 | html: options.htmlMessage, 35 | }; 36 | 37 | await transporter.sendMail(mailOptions); 38 | }; 39 | 40 | export default sendEmail; 41 | -------------------------------------------------------------------------------- /src/utils/emailMessages.ts: -------------------------------------------------------------------------------- 1 | export const formConfirmationMessage = ( 2 | email: string, 3 | verificationCode: string 4 | ) => 5 | `Hi ${email}, 6 | Welcome to Telware! We're excited to have you onboard. 7 | Please verify your email address by entering the following confirmation code: 8 | ${verificationCode} 9 | If you didn't request this, please ignore this email. 10 | Best regards, 11 | -Telware Team 🐦‍⬛ 12 | If you have any questions, feel free to reach out to us at telware.sw@gmail.com`; 13 | 14 | export const formConfirmationMessageHtml = ( 15 | email: string, 16 | verificationCode: string 17 | ) => ` 18 | 19 | 20 | 21 | 22 | 23 | 24 | Email Confirmation 25 | 86 | 87 | 88 | 106 | 107 | 108 | `; 109 | 110 | export const formResetPasswordMessage = (email: string, resetURL: string) => 111 | `Hi ${email}, 112 | Forgot your password? Click this link to set your new password: 113 | ${resetURL} 114 | 115 | If you didn't forget your password, please ignore this email. 116 | Best regards, 117 | -Telware Team 🐦‍⬛ 118 | If you have any questions, feel free to reach out to us at telware.sw@gmail.com`; 119 | 120 | export const formResetPasswordMessageHtml = (email: string, resetURL: string) => 121 | ` 122 | 123 | 124 | 125 | 126 | Password Reset 127 | 160 | 161 | 162 |
163 |
164 | Telware Logo 165 |

Password Reset Request

166 |
167 |

Hi ${email},

168 |

Forgot your password?

169 |

Click Here

170 |

If you didn't forget your password, please ignore this email.

171 | 176 |
177 | 178 | 179 | `; 180 | -------------------------------------------------------------------------------- /src/utils/encryption.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | export const encryptKey = (key: String) => { 4 | const cipher = crypto.createCipheriv( 5 | 'aes-256-gcm', 6 | Buffer.from(process.env.ENCRYPTION_KEY_SECRET as string, 'hex'), 7 | Buffer.from(process.env.ENCRYPTION_KEY_IV as string, 'hex') 8 | ); 9 | 10 | const encrypted = Buffer.concat([ 11 | cipher.update(Buffer.from(key, 'hex')), 12 | cipher.final(), 13 | ]).toString('hex'); 14 | return { encrypted, authTag: cipher.getAuthTag().toString('hex') }; 15 | }; 16 | 17 | export const decryptKey = (key: String, authTag: String) => { 18 | const decipher = crypto.createDecipheriv( 19 | 'aes-256-gcm', 20 | Buffer.from(process.env.ENCRYPTION_KEY_SECRET as string, 'hex'), 21 | Buffer.from(process.env.ENCRYPTION_KEY_IV as string, 'hex') 22 | ); 23 | decipher.setAuthTag(Buffer.from(authTag, 'hex')); 24 | 25 | return Buffer.concat([ 26 | decipher.update(Buffer.from(key, 'hex')), 27 | decipher.final(), 28 | ]).toString('hex'); 29 | }; 30 | 31 | export const encryptMessage = (message: String, key: String, iv: String) => { 32 | const cipher = crypto.createCipheriv( 33 | 'aes-256-cbc', 34 | Buffer.from(key, 'hex'), 35 | Buffer.from(iv, 'hex') 36 | ); 37 | 38 | return Buffer.concat([ 39 | cipher.update(Buffer.from(message)), 40 | cipher.final(), 41 | ]).toString('hex'); 42 | }; 43 | -------------------------------------------------------------------------------- /src/utils/generateConfirmationCode.ts: -------------------------------------------------------------------------------- 1 | const generateConfirmationCode = () => { 2 | const confirmationCode: string = Math.floor(Math.random() * 1000000) 3 | .toString() 4 | .padStart(6, '0'); 5 | return confirmationCode; 6 | }; 7 | 8 | export default generateConfirmationCode; 9 | -------------------------------------------------------------------------------- /src/utils/static-analysis-script.mjs: -------------------------------------------------------------------------------- 1 | import { ESLint } from 'eslint'; 2 | import fs from 'fs'; 3 | 4 | async function generateStaticAnalysisReport() { 5 | const eslint = new ESLint(); 6 | 7 | const results = await eslint.lintFiles(['src/**/*.{js,jsx,ts,tsx}']); 8 | 9 | const report = { 10 | totalFiles: results.length, 11 | errorCount: results.reduce((sum, result) => sum + result.errorCount, 0), 12 | warningCount: results.reduce((sum, result) => sum + result.warningCount, 0), 13 | fixableErrorCount: results.reduce( 14 | (sum, result) => sum + result.fixableErrorCount, 15 | 0 16 | ), 17 | fixableWarningCount: results.reduce( 18 | (sum, result) => sum + result.fixableWarningCount, 19 | 0 20 | ), 21 | details: results.map((result) => ({ 22 | filePath: result.filePath, 23 | errors: result.messages.filter((m) => m.severity === 2), 24 | warnings: result.messages.filter((m) => m.severity === 1), 25 | })), 26 | }; 27 | 28 | fs.writeFileSync( 29 | 'static-analysis-report.json', 30 | JSON.stringify(report, null, 2) 31 | ); 32 | 33 | console.log('Static Analysis Report Generated'); 34 | } 35 | 36 | generateStaticAnalysisReport(); 37 | -------------------------------------------------------------------------------- /static.cloc.json: -------------------------------------------------------------------------------- 1 | {"header" : { 2 | "cloc_url" : "github.com/AlDanial/cloc", 3 | "cloc_version" : "1.90", 4 | "elapsed_seconds" : 0.0261929035186768, 5 | "n_files" : 67, 6 | "n_lines" : 4033, 7 | "files_per_second" : 2557.94474836384, 8 | "lines_per_second" : 153973.002539573, 9 | "report_file" : "static.cloc.json"}, 10 | "TypeScript" :{ 11 | "nFiles": 63, 12 | "blank": 430, 13 | "comment": 60, 14 | "code": 3389}, 15 | "JSON" :{ 16 | "nFiles": 2, 17 | "blank": 0, 18 | "comment": 0, 19 | "code": 59}, 20 | "JavaScript" :{ 21 | "nFiles": 2, 22 | "blank": 8, 23 | "comment": 49, 24 | "code": 38}, 25 | "SUM": { 26 | "blank": 438, 27 | "comment": 109, 28 | "code": 3486, 29 | "nFiles": 67} } 30 | --------------------------------------------------------------------------------