diff --git a/.migrate b/.migrate index 821470e6..566ba032 100644 --- a/.migrate +++ b/.migrate @@ -1,9 +1,9 @@ { - "lastRun": "1720532975152-add-isfetchingintialdata.ts", + "lastRun": "1738072787796-update-user-documents-for-telegram-login.ts", "migrations": [ { - "title": "1720532975152-add-isfetchingintialdata.ts", - "timestamp": 1720533282435 + "title": "1738072787796-update-user-documents-for-telegram-login.ts", + "timestamp": 1738073593695 } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b50280d4..96cd2986 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@notionhq/client": "^2.2.3", "@sentry/node": "^7.50.0", "@temporalio/client": "^1.11.3", - "@togethercrew.dev/db": "^3.0.79", + "@togethercrew.dev/db": "^3.2.3", "@togethercrew.dev/tc-messagebroker": "^0.0.50", "@types/express-session": "^1.17.7", "@types/morgan": "^1.9.5", @@ -40,7 +40,7 @@ "moment": "^2.29.4", "moment-timezone": "^0.5.40", "mongodb": "^4.12.1", - "mongoose": "^6.7.5", + "mongoose": "^6.13.8", "morgan": "^1.10.0", "neo4j-driver": "^5.9.0", "node-fetch": "^2.6.7", @@ -2427,9 +2427,10 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" }, "node_modules/@mongodb-js/saslprep": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.0.tgz", - "integrity": "sha512-Xfijy7HvfzzqiOAhAepF4SGN5e9leLkMvg/OPOF97XemjfVCYN/oWa75wnkc6mltMSTwY+XlbhWgUOJmkFspSw==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", + "integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==", + "license": "MIT", "optional": true, "dependencies": { "sparse-bitfield": "^3.0.3" @@ -3658,9 +3659,9 @@ "dev": true }, "node_modules/@togethercrew.dev/db": { - "version": "3.0.79", - "resolved": "https://registry.npmjs.org/@togethercrew.dev/db/-/db-3.0.79.tgz", - "integrity": "sha512-l3CWsmnXZTwHs50/oN0bT+UCxJJSsKjGlgOYwJADQY8+bk3gz7kKTfSE7d8Jc873bVUO54kEfPLHI1/NjzArng==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@togethercrew.dev/db/-/db-3.2.3.tgz", + "integrity": "sha512-V2NqrrXyuJKornzhAfZymWYATp6KkjXrMxNPgb0c7LCsIo+tRLlqqQIkwYt5htI9ujBP5bemgtYHYykhQhWdpA==", "license": "ISC", "dependencies": { "discord.js": "^14.7.1", @@ -3671,6 +3672,20 @@ "validator": "^13.7.0" } }, + "node_modules/@togethercrew.dev/db/node_modules/mongoose-unique-validator": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mongoose-unique-validator/-/mongoose-unique-validator-3.1.0.tgz", + "integrity": "sha512-UsBBlFapip8gc8x1h+nLWnkOy+GTy9Z+zmTyZ35icLV3EoLIVz180vJzepfMM9yBy2AJh+maeuoM8CWtqejGUg==", + "license": "MIT", + "dependencies": { + "lodash.foreach": "^4.1.0", + "lodash.get": "^4.0.2", + "lodash.merge": "^4.6.2" + }, + "peerDependencies": { + "mongoose": "^6.0.0" + } + }, "node_modules/@togethercrew.dev/tc-messagebroker": { "version": "0.0.50", "resolved": "https://registry.npmjs.org/@togethercrew.dev/tc-messagebroker/-/tc-messagebroker-0.0.50.tgz", @@ -3735,9 +3750,9 @@ } }, "node_modules/@togethercrew.dev/tc-messagebroker/node_modules/mongoose": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.8.3.tgz", - "integrity": "sha512-eFnbkKgyVrICoHB6tVJ4uLanS7d5AIo/xHkEbQeOv6g2sD7gh/1biRwvFifsmbtkIddQVNr3ROqHik6gkknN3g==", + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.8.6.tgz", + "integrity": "sha512-1oVPRHvcmPVwk/zeSTEzayzQEVeYQM1D5zrkLsttfNNB7pPRUmkKeFu6gpbvyEswOuZLrWJjqB8kSTY+k2AZOA==", "license": "MIT", "dependencies": { "bson": "^5.5.0", @@ -3756,18 +3771,6 @@ "url": "https://opencollective.com/mongoose" } }, - "node_modules/@togethercrew.dev/tc-messagebroker/node_modules/mquery": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", - "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", - "license": "MIT", - "dependencies": { - "debug": "4.x" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@togethercrew.dev/tc-messagebroker/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9590,6 +9593,7 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==", + "license": "Apache-2.0", "engines": { "node": ">=12.0.0" } @@ -9718,7 +9722,8 @@ "node_modules/lodash.foreach": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz", - "integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==" + "integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==", + "license": "MIT" }, "node_modules/lodash.get": { "version": "4.4.2", @@ -10447,9 +10452,9 @@ } }, "node_modules/mongoose": { - "version": "6.13.5", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.13.5.tgz", - "integrity": "sha512-podJEaIF/5N2mQymkyyUzN2NeL/68MOyYjf3O0zsgCU2B2Omnhg6NhGHVavt9ZH/VxOrwKE9XphbuHDFK+T06g==", + "version": "6.13.8", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.13.8.tgz", + "integrity": "sha512-JHKco/533CyVrqCbyQsnqMpLn8ZCiKrPDTd2mvo2W7ygIvhygWjX2wj+RPjn6upZZgw0jC6U51RD7kUsyK8NBg==", "license": "MIT", "dependencies": { "bson": "^4.7.2", @@ -10468,17 +10473,16 @@ "url": "https://opencollective.com/mongoose" } }, - "node_modules/mongoose-unique-validator": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mongoose-unique-validator/-/mongoose-unique-validator-3.1.0.tgz", - "integrity": "sha512-UsBBlFapip8gc8x1h+nLWnkOy+GTy9Z+zmTyZ35icLV3EoLIVz180vJzepfMM9yBy2AJh+maeuoM8CWtqejGUg==", + "node_modules/mongoose/node_modules/mquery": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.3.tgz", + "integrity": "sha512-J5heI+P08I6VJ2Ky3+33IpCdAvlYGTSUjwTPxkAr8i8EoduPMBX2OY/wa3IKZIQl7MU4SbFk8ndgSKyB/cl1zA==", + "license": "MIT", "dependencies": { - "lodash.foreach": "^4.1.0", - "lodash.get": "^4.0.2", - "lodash.merge": "^4.6.2" + "debug": "4.x" }, - "peerDependencies": { - "mongoose": "^6.0.0" + "engines": { + "node": ">=12.0.0" } }, "node_modules/mongoose/node_modules/ms": { @@ -10534,14 +10538,15 @@ } }, "node_modules/mquery": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.3.tgz", - "integrity": "sha512-J5heI+P08I6VJ2Ky3+33IpCdAvlYGTSUjwTPxkAr8i8EoduPMBX2OY/wa3IKZIQl7MU4SbFk8ndgSKyB/cl1zA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", "dependencies": { "debug": "4.x" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" } }, "node_modules/ms": { @@ -11464,9 +11469,10 @@ "dev": true }, "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", "engines": { "node": ">=6" } @@ -12214,7 +12220,8 @@ "node_modules/sift": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", - "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==" + "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==", + "license": "MIT" }, "node_modules/signal-exit": { "version": "3.0.7", diff --git a/package.json b/package.json index cc0627ff..7457546a 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@notionhq/client": "^2.2.3", "@sentry/node": "^7.50.0", "@temporalio/client": "^1.11.3", - "@togethercrew.dev/db": "^3.0.79", + "@togethercrew.dev/db": "^3.2.3", "@togethercrew.dev/tc-messagebroker": "^0.0.50", "@types/express-session": "^1.17.7", "@types/morgan": "^1.9.5", @@ -54,7 +54,7 @@ "moment": "^2.29.4", "moment-timezone": "^0.5.40", "mongodb": "^4.12.1", - "mongoose": "^6.7.5", + "mongoose": "^6.13.8", "morgan": "^1.10.0", "neo4j-driver": "^5.9.0", "node-fetch": "^2.6.7", diff --git a/src/app.ts b/src/app.ts index 5b440a11..25105ef7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,17 +1,18 @@ +import compression from 'compression'; +import cors from 'cors'; import express, { Application } from 'express'; +import session from 'express-session'; import helmet from 'helmet'; -import compression from 'compression'; +import httpStatus from 'http-status'; import passport from 'passport'; + +import { bullBoardServerAdapter } from './bullmq'; +import config from './config'; +import morgan from './config/morgan'; import { jwtStrategy } from './config/passport'; -import cors from 'cors'; -import httpStatus from 'http-status'; import { error, sentry } from './middlewares'; -import { ApiError } from './utils'; import routes from './routes/v1'; -import morgan from './config/morgan'; -import config from './config'; -import session from 'express-session'; -import { bullBoardServerAdapter } from './bullmq'; +import { ApiError } from './utils'; const app: Application = express(); diff --git a/src/config/index.ts b/src/config/index.ts index 8a700549..3915b1dd 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -13,6 +13,7 @@ const envVarsSchema = Joi.object() RABBIT_PORT: Joi.string().required().description('RabbitMQ port'), RABBIT_USER: Joi.string().required().description('RabbitMQ username'), RABBIT_PASSWORD: Joi.string().required().description('RabbitMQ password'), + TELEGRAM_BOT_TOKEN: Joi.string().required().description('Telegram bot token'), DISCORD_CLIENT_ID: Joi.string().required().description('Discord clinet id'), DISCORD_CLIENT_SECRET: Joi.string().required().description('Discord clinet secret'), DISCORD_BOT_TOKEN: Joi.string().required().description('Discord bot token'), @@ -134,6 +135,9 @@ export default { connect: envVars.NOTION_CONNECT_CALLBACK_URI, }, }, + telegram: { + botToken: envVars.TELEGRAM_BOT_TOKEN, + }, }, jwt: { secret: envVars.JWT_SECRET, diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 015e505d..5b8ab605 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -5,8 +5,8 @@ import querystring from 'querystring'; import config from '../config'; import logger from '../config/logger'; import { discord, generateState } from '../config/oAtuh2'; -import { ISessionRequest } from '../interfaces'; -import { authService, discordServices, tokenService, userService } from '../services'; +import { ISessionRequest, TelegramCallbackParams } from '../interfaces'; +import { authService, discordServices, telegramService, tokenService } from '../services'; import { catchAsync } from '../utils'; const discordAuthorize = catchAsync(async function (req: ISessionRequest, res: Response) { @@ -18,47 +18,41 @@ const discordAuthorize = catchAsync(async function (req: ISessionRequest, res: R }); const discordAuthorizeCallback = catchAsync(async function (req: ISessionRequest, res: Response) { - const STATUS_CODE_SINGIN = 1001; - const STATUS_CODE_LOGIN = 1002; - const STATUS_CODE_ERROR = 1003; - const code = req.query.code as string; - const returnedState = req.query.state as string; - const storedState = req.session.state; - let statusCode = STATUS_CODE_LOGIN; try { - if (!code || !returnedState || returnedState !== storedState) { - throw new Error('Invalid code or state mismatch'); - } - const discordOathCallback = await discordServices.coreService.exchangeCode( + const code = req.query.code as string; + const returnedState = req.query.state as string; + const storedState = req.session.state; + const redirectUrl = await discordServices.authService.handleOAuthCallback({ code, - config.oAuth2.discord.callbackURI.authorize, - ); - const discordUser = await discordServices.coreService.getUserFromDiscordAPI(discordOathCallback.access_token); - let user = await userService.getUserByFilter({ discordId: discordUser.id }); + state: returnedState, + storedState, + }); - if (!user) { - user = await userService.createUser({ discordId: discordUser.id }); - statusCode = STATUS_CODE_SINGIN; - } - tokenService.saveDiscordOAuth2Tokens(user.id, discordOathCallback); - const tokens = await tokenService.generateAuthTokens(user); + res.redirect(redirectUrl); + } catch (err) { + logger.error({ err }, 'Failed to authorize Discord account'); const params = { - statusCode: statusCode, - accessToken: tokens.access.token, - accessExp: tokens.access.expires.toString(), - refreshToken: tokens.refresh.token, - refreshExp: tokens.refresh.expires.toString(), + statusCode: 1003, }; const query = querystring.stringify(params); - res.redirect(`${config.frontend.url}/callback?` + query); + res.redirect(`${config.frontend.url}/callback?${query}`); + } +}); + +const telegramAuthorizeCallback = catchAsync(async function (req: Request, res: Response) { + try { + const params: TelegramCallbackParams = req.query as unknown as TelegramCallbackParams; + + const redirectUrl = await telegramService.authService.handleOAuthCallback(params); + res.redirect(redirectUrl); } catch (err) { - logger.error({ err }, 'Failed to authorize discord account'); + logger.error({ err }, 'Failed to authorize Telegram account'); const params = { - statusCode: STATUS_CODE_ERROR, + statusCode: 1003, }; const query = querystring.stringify(params); - res.redirect(`${config.frontend.url}/callback?` + query); + res.redirect(`${config.frontend.url}/callback?${query}`); } }); @@ -78,10 +72,6 @@ const generateToken = catchAsync(async function (req: Request, res: Response) { res.send(token); }); -const telegramAuthorizeCallback = catchAsync(async function (req: Request, res: Response) { - console.log(req.body, req.query, req.params); - res.send('Hi'); -}); export default { discordAuthorize, discordAuthorizeCallback, diff --git a/src/controllers/platform.controller.ts b/src/controllers/platform.controller.ts index f7da1bc9..512af1de 100644 --- a/src/controllers/platform.controller.ts +++ b/src/controllers/platform.controller.ts @@ -1,24 +1,26 @@ import { Response } from 'express'; +import httpStatus from 'http-status'; +import querystring from 'querystring'; + +import { DatabaseManager, PlatformNames } from '@togethercrew.dev/db'; + +import config from '../config'; +import parentLogger from '../config/logger'; +import { discord, generateCodeChallenge, generateCodeVerifier, generateState, google, twitter } from '../config/oAtuh2'; +import { IAuthAndPlatform, ISessionRequest } from '../interfaces'; +import { IAuthRequest } from '../interfaces/Request.interface'; import { - platformService, - twitterService, discordServices, - googleService, - userService, - tokenService, + discourseService, githubService, + googleService, notionService, - discourseService, + platformService, + tokenService, + twitterService, + userService, } from '../services'; -import { IAuthRequest } from '../interfaces/Request.interface'; -import { catchAsync, pick, ApiError } from '../utils'; -import { generateState, generateCodeVerifier, generateCodeChallenge, twitter, discord, google } from '../config/oAtuh2'; -import { ISessionRequest, IAuthAndPlatform } from '../interfaces'; -import config from '../config'; -import httpStatus from 'http-status'; -import querystring from 'querystring'; -import parentLogger from '../config/logger'; -import { PlatformNames, DatabaseManager } from '@togethercrew.dev/db'; +import { ApiError, catchAsync, pick } from '../utils'; const logger = parentLogger.child({ module: 'PlatformController' }); @@ -321,24 +323,14 @@ const getPlatform = catchAsync(async function (req: IAuthRequest, res: Response) res.send(platform); }); const updatePlatform = catchAsync(async function (req: IAuthAndPlatform, res: Response) { - if ( - req.platform.name === PlatformNames.Discord && - req.platform.metadata?.isInProgress && - (req.body.metadata.selectedChannels || req.body.metadata.period) - ) { - throw new ApiError( - httpStatus.BAD_REQUEST, - 'Updating channels or date period is not allowed during server analysis.', - ); - } - - if (req.platform.name === PlatformNames.Discord && req.platform.metadata?.isFetchingInitialData) { - throw new ApiError( - httpStatus.BAD_REQUEST, - 'Updating channels or date periods is not allowed during the initial fetching of the server.', - ); + validatePlatformUpdate(req.platform, req.body); + if (req.platform.name === PlatformNames.Discord) { + const discordIdentity = userService.getIdentityByProvider(req.user.identities, PlatformNames.Discord); + if (discordIdentity) { + await platformService.notifyDiscordUserImportComplete(req.platform.id, discordIdentity.id); + } } - const platform = await platformService.updatePlatform(req.platform, req.body, req.user.discordId); + const platform = await platformService.updatePlatform(req.platform, req.body); res.send(platform); }); const deletePlatform = catchAsync(async function (req: IAuthAndPlatform, res: Response) { @@ -392,6 +384,23 @@ const requestAccess = catchAsync(async function (req: ISessionRequest, res: Resp res.redirect(discordUrl); } }); +const validatePlatformUpdate = (platform: IAuthAndPlatform['platform'], body: IAuthAndPlatform['body']) => { + if (platform.name !== PlatformNames.Discord) return; + + if (platform.metadata?.isInProgress && (body.metadata?.selectedChannels || body.metadata?.period)) { + throw new ApiError( + httpStatus.BAD_REQUEST, + 'Updating channels or date period is not allowed during server analysis.', + ); + } + + if (platform.metadata?.isFetchingInitialData && (body.metadata?.selectedChannels || body.metadata?.period)) { + throw new ApiError( + httpStatus.BAD_REQUEST, + 'Updating channels or date periods is not allowed during the initial fetching of the server.', + ); + } +}; export default { createPlatform, diff --git a/src/interfaces/Telegram.interface.ts b/src/interfaces/Telegram.interface.ts new file mode 100644 index 00000000..0a6e4632 --- /dev/null +++ b/src/interfaces/Telegram.interface.ts @@ -0,0 +1,10 @@ +export interface TelegramCallbackParams { + id: string; + first_name?: string; + last_name?: string; + username?: string; + photo_url?: string; + auth_date: string; + hash: string; + [key: string]: string | undefined; +} diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 5f276a5d..6a61a131 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -6,3 +6,4 @@ export * from './Guild.interface'; export * from './Twitter.interface'; export * from './Role.interface'; export * from './Airflow.interface'; +export * from './Telegram.interface'; diff --git a/src/migrations/db/1720532975152-add-isfetchingintialdata.ts b/src/migrations/db/1720532975152-add-isfetchingintialdata.ts deleted file mode 100644 index b4615fd5..00000000 --- a/src/migrations/db/1720532975152-add-isfetchingintialdata.ts +++ /dev/null @@ -1,31 +0,0 @@ -import 'dotenv/config'; -import mongoose from 'mongoose'; -import config from '../../config'; -import logger from '../../config/logger'; -import { Platform, PlatformNames } from '@togethercrew.dev/db'; - -const connectToMongoDB = async () => { - try { - await mongoose.connect(config.mongoose.serverURL); - - logger.info('Connected to MongoDB!'); - } catch (error) { - logger.fatal('Failed to connect to MongoDB!'); - } -}; - -export const up = async () => { - // Connect to MongoDB - await connectToMongoDB(); - const discordPlatforms = await Platform.find({ name: PlatformNames.Discord }); - for (let i = 0; i < discordPlatforms.length; i++) { - const platform = discordPlatforms[i]; - if (platform?.metadata) { - platform.metadata.isFetchingInitialData = false; - platform.markModified('metadata'); - await platform.save(); - } - } -}; - -export const down = async () => {}; diff --git a/src/migrations/db/1738072787796-update-user-documents-for-telegram-login.ts b/src/migrations/db/1738072787796-update-user-documents-for-telegram-login.ts new file mode 100644 index 00000000..5160febb --- /dev/null +++ b/src/migrations/db/1738072787796-update-user-documents-for-telegram-login.ts @@ -0,0 +1,78 @@ +import 'dotenv/config'; + +import mongoose from 'mongoose'; + +import { PlatformNames, User } from '@togethercrew.dev/db'; + +import config from '../../config'; +import logger from '../../config/logger'; + +async function connectToMongoDB() { + try { + await mongoose.connect(config.mongoose.serverURL); + logger.info('Connected to MongoDB!'); + } catch (error) { + logger.fatal('Failed to connect to MongoDB!'); + throw error; + } +} + +export const up = async () => { + await connectToMongoDB(); + + // This single pipeline: + // 1) sets `identities` to an array containing { provider: 'discord', id: '$discordId' } + // 2) unsets the old `discordId` and `email` fields + const result = await User.updateMany( + { + discordId: { $exists: true }, + }, + [ + { + $set: { + identities: [ + { + provider: PlatformNames.Discord, + id: '$discordId', + }, + ], + }, + }, + { + $unset: ['discordId', 'email'], + }, + ], + ); + + logger.info(`Up migration: modified ${result.modifiedCount} user(s).`); + + await mongoose.connection.close(); +}; + +export const down = async () => { + await connectToMongoDB(); + + // We'll do a quick find() and loop if you want to revert, because the pipeline + // would need to parse out "discordId" from identities. + const users = await User.find({ + identities: { $exists: true, $ne: [] }, + }); + + for (const userDoc of users) { + // cast to any or .get('...') + const user: any = userDoc; + + const discordIdentity = user.identities.find((identity: any) => identity.provider === PlatformNames.Discord); + if (discordIdentity) { + user.discordId = discordIdentity.id; + } + user.identities = []; + + // If needed, re-add `email` from some backup, if you have it. + user.markModified('identities'); + await user.save(); + } + + logger.info('Down migration: successfully reverted to old schema.'); + await mongoose.connection.close(); +}; diff --git a/src/routes/v1/auth.route.ts b/src/routes/v1/auth.route.ts index 625ea8d5..ffb29924 100644 --- a/src/routes/v1/auth.route.ts +++ b/src/routes/v1/auth.route.ts @@ -9,7 +9,7 @@ const router = express.Router(); // Routes router.get('/discord/authorize', authController.discordAuthorize); router.get('/discord/authorize/callback', authController.discordAuthorizeCallback); -router.get('/telegram/authorize/callback', authController.discordAuthorizeCallback); +router.get('/telegram/authorize/callback', authController.telegramAuthorizeCallback); router.post('/generate-token', auth(), validate(authValidation.generateToken), authController.generateToken); router.post('/logout', validate(authValidation.logout), authController.logout); diff --git a/src/services/discord/auth.service.ts b/src/services/discord/auth.service.ts new file mode 100644 index 00000000..6cb119a0 --- /dev/null +++ b/src/services/discord/auth.service.ts @@ -0,0 +1,52 @@ +import querystring from 'querystring'; + +import { PlatformNames } from '@togethercrew.dev/db'; + +import config from '../../config'; +import tokenService from '../token.service'; +import userService from '../user.service'; +import coreService from './core.service'; + +const STATUS_CODE_SIGNIN = 1001; +const STATUS_CODE_LOGIN = 1002; + +interface OAuthCallbackParams { + code: string; + state: string; + storedState: string; +} + +const handleOAuthCallback = async (params: OAuthCallbackParams) => { + const { code, state: returnedState, storedState } = params; + let statusCode = STATUS_CODE_LOGIN; + + if (!code || !returnedState || returnedState !== storedState) { + throw new Error('Invalid code or state mismatch'); + } + + const oauthCallbackData = await coreService.exchangeCode(code, config.oAuth2.discord.callbackURI.authorize); + const discordUser = await coreService.getUserFromDiscordAPI(oauthCallbackData.access_token); + + const userId = discordUser.id; + let user = await userService.getUserByIdentity(PlatformNames.Discord, userId); + if (!user) { + user = await userService.createUserWithIdentity(PlatformNames.Discord, userId); + statusCode = STATUS_CODE_SIGNIN; + } + tokenService.saveDiscordOAuth2Tokens(user.id, oauthCallbackData); + const tokens = await tokenService.generateAuthTokens(user); + const paramsToRedirect = { + statusCode: statusCode, + accessToken: tokens.access.token, + accessExp: tokens.access.expires.toString(), + refreshToken: tokens.refresh.token, + refreshExp: tokens.refresh.expires.toString(), + }; + + const query = querystring.stringify(paramsToRedirect); + return `${config.frontend.url}/callback?${query}`; +}; + +export default { + handleOAuthCallback, +}; diff --git a/src/services/discord/index.ts b/src/services/discord/index.ts index df343a41..c4ff668d 100644 --- a/src/services/discord/index.ts +++ b/src/services/discord/index.ts @@ -1,13 +1,13 @@ import channelService from './channel.service'; import roleService from './role.service'; import guildMemberService from './guildMember.service'; -// import guildService from './guild.service'; import coreService from './core.service'; +import authService from './auth.service'; export default { channelService, roleService, guildMemberService, - // guildService, coreService, + authService, }; diff --git a/src/services/platform.service.ts b/src/services/platform.service.ts index 0f8ac3b2..5b96cf71 100644 --- a/src/services/platform.service.ts +++ b/src/services/platform.service.ts @@ -1,12 +1,16 @@ -import { HydratedDocument, Types, FilterQuery } from 'mongoose'; +import { Snowflake } from 'discord.js'; import httpStatus from 'http-status'; -import { Platform, IPlatform } from '@togethercrew.dev/db'; +import { FilterQuery, HydratedDocument, Types } from 'mongoose'; + +import { IPlatform, Platform, PlatformNames } from '@togethercrew.dev/db'; + +import { analyzerAction, analyzerWindow } from '../config/analyzer.statics'; +import parentLogger from '../config/logger'; import ApiError from '../utils/ApiError'; -import sagaService from './saga.service'; import discourseService from './discourse'; -import { Snowflake } from 'discord.js'; -import { analyzerAction, analyzerWindow } from '../config/analyzer.statics'; -import { PlatformNames } from '@togethercrew.dev/db'; +import sagaService from './saga.service'; + +const logger = parentLogger.child({ module: 'PlatformService' }); /** * Create a platform @@ -116,7 +120,6 @@ const updatePlatformByFilter = async ( const updatePlatform = async ( platform: HydratedDocument, updateBody: Partial, - userDiscordId?: Snowflake, ): Promise> => { if (updateBody.metadata) { updateBody.metadata = { @@ -124,15 +127,6 @@ const updatePlatform = async ( ...updateBody.metadata, }; } - if ((updateBody.metadata?.period || updateBody.metadata?.selectedChannels) && userDiscordId) { - await sagaService.createAndStartGuildSaga(platform._id, { - created: false, - discordId: userDiscordId, - message: - 'Your data import into TogetherCrew is complete! See your insights on your dashboard https://app.togethercrew.com/. If you have questions send a DM to katerinabc (Discord) or k_bc0 (Telegram).', - useFallback: true, - }); - } Object.assign(platform, updateBody); return await platform.save(); @@ -266,6 +260,32 @@ const managePlatformConnection = async ( ); }; +/** + * Sends a notification to the discord user upon successful data import. + * + * @param platformId - The ID of the platform. + * @param userDiscordId - The Discord ID of the user. + */ +const notifyDiscordUserImportComplete = async (platformId: Types.ObjectId, userDiscordId: Snowflake): Promise => { + const IMPORT_COMPLETE_MESSAGE = ` +Your data import into TogetherCrew is complete! +See your insights on your dashboard: https://app.togethercrew.com/. +If you have questions, send a DM to katerinabc (Discord) or k_bc0 (Telegram). +`; + + try { + await sagaService.createAndStartGuildSaga(platformId, { + created: false, + discordId: userDiscordId, + message: IMPORT_COMPLETE_MESSAGE.trim(), + useFallback: true, + }); + logger.info(`Notification sent to Discord ID: ${userDiscordId}`); + } catch (error) { + logger.error(error, `Failed to send notification to Discord ID: ${userDiscordId}`); + } +}; + export default { createPlatform, getPlatformById, @@ -277,4 +297,5 @@ export default { deletePlatformByFilter, managePlatformConnection, callExtractionApp, + notifyDiscordUserImportComplete, }; diff --git a/src/services/telegram/auth.service.ts b/src/services/telegram/auth.service.ts new file mode 100644 index 00000000..26f8f26b --- /dev/null +++ b/src/services/telegram/auth.service.ts @@ -0,0 +1,71 @@ +import crypto from 'crypto'; +import querystring from 'querystring'; + +import { PlatformNames } from '@togethercrew.dev/db'; + +import config from '../../config'; +import { TelegramCallbackParams } from '../../interfaces'; +import tokenService from '../token.service'; +import userService from '../user.service'; + +const STATUS_CODE_SIGNIN = 1001; +const STATUS_CODE_LOGIN = 1002; + +/** + * Verifies the authorization hash provided by Telegram. + * @param hash - The hash received from Telegram. + * @param dataCheckString - The data string used to calculate the hash. + * @throws Will throw an error if the calculated hash does not match the provided hash. + */ +const checkAuthorization = async (hash: string, dataCheckString: string): Promise => { + const secretKey = crypto.createHash('sha256').update(config.oAuth2.telegram.botToken).digest(); + + const hmac = crypto.createHmac('sha256', secretKey); + hmac.update(dataCheckString); + const calculatedHash = hmac.digest('hex'); + + if (calculatedHash !== hash) { + throw new Error('Data is NOT from Telegram'); + } +}; + +/** + * Handles the OAuth callback from Telegram. + * @param params - The callback parameters from Telegram. + * @returns A URL string to redirect the user to the frontend application. + */ +const handleOAuthCallback = async (params: TelegramCallbackParams): Promise => { + let statusCode = STATUS_CODE_LOGIN; + + const { hash, ...data } = params; + + const sortedKeys = Object.keys(data).sort(); + const dataCheckString = sortedKeys.map((key) => `${key}=${data[key]}`).join('\n'); + + await checkAuthorization(hash, dataCheckString); + + const userId = data.id; + let user = await userService.getUserByIdentity(PlatformNames.Telegram, userId); + + if (!user) { + user = await userService.createUserWithIdentity(PlatformNames.Telegram, userId); + statusCode = STATUS_CODE_SIGNIN; + } + + const tokens = await tokenService.generateAuthTokens(user); + + const paramsToRedirect = { + statusCode, + accessToken: tokens.access.token, + accessExp: tokens.access.expires.toString(), + refreshToken: tokens.refresh.token, + refreshExp: tokens.refresh.expires.toString(), + }; + + const query = querystring.stringify(paramsToRedirect); + return `${config.frontend.url}/callback?${query}`; +}; + +export default { + handleOAuthCallback, +}; diff --git a/src/services/telegram/index.ts b/src/services/telegram/index.ts index c1adf8fd..471f4ca1 100644 --- a/src/services/telegram/index.ts +++ b/src/services/telegram/index.ts @@ -1,3 +1,4 @@ +import authService from './auth.service'; import heatmapService from './heatmap.service'; import memberActivityService from './memberActivity.service'; import membersService from './members.service'; @@ -6,4 +7,5 @@ export default { heatmapService, memberActivityService, membersService, + authService, }; diff --git a/src/services/user.service.ts b/src/services/user.service.ts index ad0a548e..fbf584b9 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -1,6 +1,8 @@ -import { HydratedDocument, Types } from 'mongoose'; import httpStatus from 'http-status'; -import { User, IUser } from '@togethercrew.dev/db'; +import { HydratedDocument, Types } from 'mongoose'; + +import { IIdentity, IUser, PlatformNames, User } from '@togethercrew.dev/db'; + import ApiError from '../utils/ApiError'; /** @@ -88,6 +90,77 @@ const addCommunityToUserById = async (userId: Types.ObjectId, communityId: Types return user; }; +/** + * Get user by provider and providerId + * @param {string} provider - The authentication provider (e.g., 'discord') + * @param {string} userId - The unique ID from the provider + * @returns {Promise | null>} + */ +const getUserByIdentity = async (provider: PlatformNames, userId: string): Promise | null> => { + return User.findOne({ + identities: { + $elemMatch: { provider, id: userId }, + }, + }); +}; + +/** + * Create a user with a specific identity + * @param {string} provider - The authentication provider (e.g., 'discord') + * @param {string} userId - The unique ID from the provider + * @param {Partial} additionalData - Any additional user data + * @returns {Promise>} + */ +const createUserWithIdentity = async ( + provider: PlatformNames, + userId: string, + additionalData: Partial = {}, +): Promise> => { + const userBody: IUser = { + identities: [ + { + provider, + id: userId, + }, + ], + ...additionalData, + }; + return createUser(userBody); +}; + +/** + * Add a new identity to an existing user + * @param {Types.ObjectId} userId + * @param {IIdentity} identity + * @returns {Promise>} + */ +const addIdentityToUser = async (userId: Types.ObjectId, identity: IIdentity): Promise> => { + const user = await User.findById(userId); + if (!user) { + throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); + } + + const existingIdentity = user.identities.find((id) => id.provider === identity.provider && id.id === identity.id); + + if (existingIdentity) { + throw new ApiError(httpStatus.BAD_REQUEST, 'Identity already exists for this user'); + } + + user.identities.push(identity); + await user.save(); + return user; +}; + +/** + * Get a specific identity from a user's identities by provider. + * @param {IIdentity[]} identities - The list of user identities. + * @param {PlatformNames} provider - The provider name to search for. + * @returns {IIdentity | undefined} - The matching identity or undefined if not found. + */ +const getIdentityByProvider = (identities: IIdentity[], provider: PlatformNames): IIdentity | undefined => { + return identities.find((identity) => identity.provider === provider); +}; + export default { createUser, getUserById, @@ -96,4 +169,8 @@ export default { updateUserById, deleteUserById, addCommunityToUserById, + getUserByIdentity, + createUserWithIdentity, + addIdentityToUser, + getIdentityByProvider, }; diff --git a/src/utils/role.util.ts b/src/utils/role.util.ts index a0497454..6efa21d9 100644 --- a/src/utils/role.util.ts +++ b/src/utils/role.util.ts @@ -1,19 +1,33 @@ -import { HydratedDocument, Types } from 'mongoose'; -import { ICommunity, IPlatform, IUser, PlatformNames, DatabaseManager } from '@togethercrew.dev/db'; -import { discordServices, platformService } from '../services'; +import { HydratedDocument } from 'mongoose'; + +import { DatabaseManager, ICommunity, IUser, PlatformNames } from '@togethercrew.dev/db'; + import { UserRole } from '../interfaces'; +import { discordServices, platformService, userService } from '../services'; + /** * Get user roles in a community * @param {HydratedDocument} user - * @param {HydratedDocument} communityId - * @returns {Promise>} + * @param {HydratedDocument} community + * @returns {Promise} */ -async function getUserRolesForCommunity(user: HydratedDocument, community: HydratedDocument) { +async function getUserRolesForCommunity( + user: HydratedDocument, + community: HydratedDocument, +): Promise { let userRoles: UserRole[] = []; + + const discordIdentity = userService.getIdentityByProvider(user.identities, PlatformNames.Discord); + if (community !== null) { if (community.users.some((id) => id.equals(user.id))) { userRoles.push('admin'); + + if (!discordIdentity) { + return userRoles; + } } + const connectedPlatformDoc = await platformService.getPlatformByFilter({ community: community.id, disconnectedAt: null, @@ -21,8 +35,13 @@ async function getUserRolesForCommunity(user: HydratedDocument, community }); if (connectedPlatformDoc !== null) { const guildConnection = await DatabaseManager.getInstance().getGuildDb(connectedPlatformDoc.metadata?.id); + + if (!discordIdentity) { + return userRoles; + } + const guildMemberDoc = await discordServices.guildMemberService.getGuildMember(guildConnection, { - discordId: user.discordId, + discordId: discordIdentity.id, }); if (community.roles) { for (let i = 0; i < community.roles?.length; i++) {