├── .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 | [](https://sonarcloud.io/summary/new_code?id=TelwareSW_telware-backend)
5 | [](https://sonarcloud.io/summary/new_code?id=TelwareSW_telware-backend)
6 | [](https://sonarcloud.io/summary/new_code?id=TelwareSW_telware-backend)
7 | [](https://sonarcloud.io/summary/new_code?id=TelwareSW_telware-backend)
8 | [](https://sonarcloud.io/summary/new_code?id=TelwareSW_telware-backend)
9 | [](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 |
58 |
59 | Filter:
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | File |
70 | |
71 | Statements |
72 | |
73 | Branches |
74 | |
75 | Functions |
76 | |
77 | Lines |
78 | |
79 |
80 |
81 |
82 |
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-backendtelware-backend
telware backend
Backend Repo for TelWare Messaging Platform
2 |
3 |
--------------------------------------------------------------------------------
/docs/functions/modules.html:
--------------------------------------------------------------------------------
1 | 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 |
89 |
93 |
94 |
Hi ${email},
95 |
We're excited to have you onboard at Telware! To get started, please verify your email address by entering the confirmation code below:
96 |
97 | ${verificationCode}
98 |
99 |
If you didn't request this, feel free to ignore this email. The code will expire in 10 minutes.
100 |
Best regards,
Telware Team 🐦⬛
101 |
102 |
105 |
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 |
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 |
--------------------------------------------------------------------------------