From 5f644c94babcdaa14b8de037b285172f66aa528d Mon Sep 17 00:00:00 2001 From: talksik Date: Thu, 2 Jun 2022 10:48:00 -0500 Subject: [PATCH] adding in old api --- packages/api/.env.development | 6 + packages/api/.gitignore | 130 +++++++++++++ packages/api/config.ts | 16 ++ packages/api/index.ts | 49 +++++ packages/api/middleware/auth.ts | 39 ++++ packages/api/nodemon.json | 6 + packages/api/package.json | 29 +++ packages/api/routes/line.ts | 216 ++++++++++++++++++++++ packages/api/routes/search.ts | 46 +++++ packages/api/routes/user.ts | 150 +++++++++++++++ packages/api/services/database.service.ts | 34 ++++ packages/api/services/line.service.ts | 148 +++++++++++++++ packages/api/services/user.service.ts | 118 ++++++++++++ packages/api/sockets/index.ts | 215 +++++++++++++++++++++ packages/api/tsconfig.json | 101 ++++++++++ 15 files changed, 1303 insertions(+) create mode 100644 packages/api/.env.development create mode 100644 packages/api/.gitignore create mode 100644 packages/api/config.ts create mode 100644 packages/api/index.ts create mode 100644 packages/api/middleware/auth.ts create mode 100644 packages/api/nodemon.json create mode 100644 packages/api/package.json create mode 100644 packages/api/routes/line.ts create mode 100644 packages/api/routes/search.ts create mode 100644 packages/api/routes/user.ts create mode 100644 packages/api/services/database.service.ts create mode 100644 packages/api/services/line.service.ts create mode 100644 packages/api/services/user.service.ts create mode 100644 packages/api/sockets/index.ts create mode 100644 packages/api/tsconfig.json diff --git a/packages/api/.env.development b/packages/api/.env.development new file mode 100644 index 0000000..3c69087 --- /dev/null +++ b/packages/api/.env.development @@ -0,0 +1,6 @@ +MONGO_CONNECTION_STRING=mongodb+srv://default:M9iZXokJlZpN4KLX@cluster0.mkuqa.mongodb.net/default?retryWrites=true&w=majority + +JWT_TOKEN_SECRET=afajdslfwk1@lkkasdfl21ASDF!2 + +GOOGLE_AUTH_CLIENT_ID=423533244953-banligobgbof8hg89i6cr1l7u0p7c2pk.apps.googleusercontent.com +GOOGLE_AUTH_CLIENT_SECRET= \ No newline at end of file diff --git a/packages/api/.gitignore b/packages/api/.gitignore new file mode 100644 index 0000000..6a7d6d8 --- /dev/null +++ b/packages/api/.gitignore @@ -0,0 +1,130 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* \ No newline at end of file diff --git a/packages/api/config.ts b/packages/api/config.ts new file mode 100644 index 0000000..54d4c97 --- /dev/null +++ b/packages/api/config.ts @@ -0,0 +1,16 @@ +import dotenv from "dotenv"; + +if (process.env && process.env.NODE_ENV === "development") { + dotenv.config({ path: ".env.development" }); +} else { + dotenv.config({ path: ".env" }); +} + +export const loadConfig = () => { + return { + MONGO_CONNECTION_STRING: process.env.MONGO_CONNECTION_STRING!, + JWT_TOKEN_SECRET: process.env.JWT_TOKEN_SECRET!, + GOOGLE_AUTH_CLIENT_ID: process.env.GOOGLE_AUTH_CLIENT_ID!, + GOOGLE_AUTH_CLIENT_SECRET: process.env.GOOGLE_AUTH_CLIENT_SECRET!, + }; +}; diff --git a/packages/api/index.ts b/packages/api/index.ts new file mode 100644 index 0000000..275c1fb --- /dev/null +++ b/packages/api/index.ts @@ -0,0 +1,49 @@ +import express, { Application, Request, Response } from "express"; + +import GetAllSocketClients from "@nirvana/core/sockets/getAllActiveSocketClients"; +import InitializeWs from "./sockets"; +import { NextFunction } from "express"; +import NirvanaResponse from "@nirvana/core/responses/nirvanaResponse"; +import ReceiveSignal from "../core/sockets/receiveSignal"; +import SendSignal from "@nirvana/core/sockets/sendSignal"; +import SocketChannels from "@nirvana/core/sockets/channels"; +import { UserService } from "./services/user.service"; +import { UserStatus } from "@nirvana/core/models"; +import cors from "cors"; +import getLineRoutes from "./routes/line"; +import getSearchRoutes from "./routes/search"; +import getUserRoutes from "./routes/user"; + +const app = express(); + +app.use(cors()); +app.use(express.json()); + +app.use((req: Request, res: Response, next: NextFunction) => { + console.log("Time: ", new Date()); + next(); +}); + +app.get("/", (req: Request, res: Response) => { + res.send("hello world."); +}); + +app.use("/api/status", (req: Request, res: Response) => { + res.json(new NirvanaResponse("wohoo, server is healthy")); +}); + +app.use("/api/user", getUserRoutes()); +app.use("/api/search", getSearchRoutes()); +app.use("/api/lines", getLineRoutes()); + +const PORT = 5000; +const server = app.listen(PORT, () => console.log("express running")); + +const io = require("socket.io")(server, { + // todo: add authentication + cors: { + origin: "*", + }, +}); + +InitializeWs(io); diff --git a/packages/api/middleware/auth.ts b/packages/api/middleware/auth.ts new file mode 100644 index 0000000..f0bccc4 --- /dev/null +++ b/packages/api/middleware/auth.ts @@ -0,0 +1,39 @@ +import { NextFunction, Request, Response } from "express"; + +import { loadConfig } from "../config"; + +const jwt = require("jsonwebtoken"); + +const config = loadConfig(); + +// used by specific routes that need to authentication +export const authCheck = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { authorization } = req.headers; + + if (!authorization) { + throw Error("No provided header"); + } + + // verify jwt token with our api secret + var decoded: JwtClaims = jwt.verify(authorization, config.JWT_TOKEN_SECRET); + + res.locals.userInfo = decoded; + + next(); + } catch (error) { + res.status(401).send("unauthorized"); + } +}; + +export interface JwtClaims { + userId: string; + googleUserId: string; + picture: string; + email: string; + name: string; +} diff --git a/packages/api/nodemon.json b/packages/api/nodemon.json new file mode 100644 index 0000000..4d05214 --- /dev/null +++ b/packages/api/nodemon.json @@ -0,0 +1,6 @@ +{ + "ignore": ["**/*.test.ts", "**/*.spec.ts", "node_modules"], + "watch": ["./"], + "exec": "npm start", + "ext": "ts" +} diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 0000000..8599526 --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,29 @@ +{ + "name": "@nirvana/api", + "version": "1.0.0", + "main": "index.ts", + "license": "MIT", + "dependencies": { + "@nirvana/core": "*", + "axios": "^0.26.1", + "cors": "^2.8.5", + "dotenv": "^16.0.0", + "express": "^4.17.3", + "google-auth-library": "^7.14.0", + "jsonwebtoken": "^8.5.1", + "mongodb": "^4.4.1", + "socket.io": "^4.4.1" + }, + "scripts": { + "dev": "NODE_ENV=development nodemon", + "start": "ts-node index.ts", + "production": "NODE_ENV=production nodemon" + }, + "devDependencies": { + "@types/express": "^4.17.13", + "@types/node": "^17.0.21", + "nodemon": "^2.0.15", + "ts-node": "^10.7.0", + "typescript": "^4.6.2" + } +} diff --git a/packages/api/routes/line.ts b/packages/api/routes/line.ts new file mode 100644 index 0000000..1236511 --- /dev/null +++ b/packages/api/routes/line.ts @@ -0,0 +1,216 @@ +import { JwtClaims, authCheck } from '../middleware/auth'; +import { Line, LineMember, LineMemberState } from '@nirvana/core/models/line.model'; +import express, { Application, Request, Response } from 'express'; + +import Content from '@nirvana/core/models/content.model'; +import CreateLineRequest from '@nirvana/core/requests/createLine.request'; +import GetConversationDetailsResponse from '@nirvana/core/responses/getConversationDetails.response'; +import GetDmConversationByOtherUserIdResponse from '@nirvana/core/responses/getDmConversationByOtherUserId.response'; +import GetUserLinesResponse from '@nirvana/core/responses/getUserLines.response'; +import { LineService } from '../services/line.service'; +import MasterLineData from '@nirvana/core/models/masterLineData.model'; +import NirvanaResponse from '@nirvana/core/responses/nirvanaResponse'; +import { ObjectId } from 'mongodb'; +import Relationship from '@nirvana/core/models/relationship.model'; +import { User } from '@nirvana/core/models/user.model'; +import { UserService } from '../services/user.service'; +import { collections } from '../services/database.service'; +import UpdateLineMemberState from '@nirvana/core/requests/updateLineMemberState.request'; + +export default function getLineRoutes() { + const router = express.Router(); + + router.use(express.json()); + + // get data for a one on one conversation + // router.get("/:otherUserGoogleUserId", authCheck, getConversationDetails); + + // create a line + router.post('/', authCheck, createLine); + + // get all of user's lines + router.get('/', authCheck, getUserLines); + + // toggle tune into a line + router.post('/:lineId/state', authCheck, updateLineMemberState); + + // get conversation between user and other user + router.get('/dm/:otherUserId', authCheck, getDmByOtherUserId); + + return router; +} + +async function getDmByOtherUserId(req: Request, res: Response) { + try { + const { otherUserId } = req.params; + const userInfo = res.locals.userInfo as JwtClaims; + + console.log(otherUserId); + + // check db for lines between me and this other person + // if there is one, then return it with 200 status + // else return it with custom status that frontend will read + + res.status(200).json(); + return; + + res.status(205).json('no such conversation between you two'); + } catch (error) { + res.status(500).json(error); + } +} + +async function createLine(req: Request, res: Response) { + try { + const reqObj: CreateLineRequest = req.body as CreateLineRequest; + console.log(req.body); + + if (!reqObj?.otherMemberIds.length) { + res.status(400).json('must provide member Ids'); + return; + } + + const userInfo = res.locals.userInfo as JwtClaims; + + const newLine = new Line( + new ObjectId(userInfo.userId), + reqObj.lineName ?? undefined, + new Date(), + new Date(), + new ObjectId(), + ); + + // TODO: validate that users exists before creating line members + const lineMembers: LineMember[] = + reqObj.otherMemberIds.map((memId) => { + const newLineMember = new LineMember( + newLine._id!, + new ObjectId(memId), + LineMemberState.INBOX, + ); + + return newLineMember; + }) ?? []; + + lineMembers.push( + new LineMember(newLine._id!, new ObjectId(userInfo.userId), LineMemberState.INBOX), + ); + + const transactionResult = await LineService.createLine(newLine, lineMembers); + + transactionResult + ? res.status(200).json(new NirvanaResponse(newLine)) + : res.status(400).json(new NirvanaResponse(undefined, new Error('unable to create line'))); + } catch (error) { + console.log(error); + res.status(500).json(error); + } +} + +async function updateLineMemberState(req: Request, res: Response) { + try { + const userInfo = res.locals.userInfo as JwtClaims; + const { lineId } = req.params; + const request = req.body as UpdateLineMemberState; + + // TODO: validation to check if user is actually a member of the line + + const result = await LineService.updateLineMemberState( + lineId, + userInfo.userId, + request.newState, + ); + + return result?.ok + ? res.status(200).json(new NirvanaResponse("successfully updated line member's state")) + : res + .status(400) + .json(new NirvanaResponse(undefined, new Error('not updated...something went wrong'))); + } catch (error) { + res.status(500).json(new NirvanaResponse(undefined, error as Error)); + } +} + +async function getUserLines(req: Request, res: Response) { + try { + const userInfo = res.locals.userInfo as JwtClaims; + + // get all of user's lineMember entries + const userLineMembers = await LineService.getLineMembersByUserId(userInfo.userId); + + if (!userLineMembers?.length) { + const resObj = new GetUserLinesResponse([]); + + res.json(new NirvanaResponse(resObj, undefined, 'this user is not in any lines')); + + return; + } + + const lineIds = userLineMembers?.map((lineMember) => lineMember.lineId) ?? []; + + // this will include the current user lineMember association to the line + const allLineMembers = await LineService.getLineMembersInLines(lineIds); + + const allLinesUsersIds: ObjectId[] = []; + allLineMembers?.map((currentLineMember) => { + if (currentLineMember?.userId) allLinesUsersIds.push(currentLineMember.userId); + }) ?? []; + + // get all users relevant here + const allRelevantUsers = await UserService.getUsersByIds(allLinesUsersIds); + + // get all lines from the list of relevant lines + const lines = (await LineService.getLinesByIds(lineIds)) ?? []; + + const masterLines: MasterLineData[] = []; + + // TODO: get the latest audio blocks for this line...maybe like today and yesterday or by block count + + lines.map((currentLine) => { + let associatedLineMembersForLine: LineMember[] = []; + + // get all of the line members for this Line + // make sure that we don't add line member if it's the current user + allLineMembers?.map((lineMember) => { + if (currentLine._id) { + if (lineMember.lineId.equals(currentLine._id)) + associatedLineMembersForLine.push(lineMember); + } + }) ?? []; + + // get the user lineMember assoc out of the list of the lineMembers for this line + const userLineMember = associatedLineMembersForLine?.find( + (currentLineMemberForLine) => + currentLineMemberForLine.userId.toString() === userInfo.userId, + ); + + if (userLineMember) { + // take out the current user from the "other" line members list now + + associatedLineMembersForLine = associatedLineMembersForLine.filter( + (currentLineMember) => currentLineMember.userId.toString() !== userInfo.userId, + ); + + // get the user objects for all of the other members + const otherUsers: User[] = []; + associatedLineMembersForLine.forEach((currentLineMember) => { + const foundUserObject = allRelevantUsers?.find((currentUser) => + currentUser._id?.equals(currentLineMember.userId), + ); + + if (foundUserObject) otherUsers.push(foundUserObject); + }); + + masterLines.push( + new MasterLineData(currentLine, userLineMember, associatedLineMembersForLine, otherUsers), + ); + } + }); + + const resObj = new GetUserLinesResponse(masterLines); + + res.json(new NirvanaResponse(resObj)); + } catch (error) { + res.status(500).json(error); + } +} diff --git a/packages/api/routes/search.ts b/packages/api/routes/search.ts new file mode 100644 index 0000000..63d75fc --- /dev/null +++ b/packages/api/routes/search.ts @@ -0,0 +1,46 @@ +import { JwtClaims, authCheck } from "../middleware/auth"; +import express, { Application, Request, Response } from "express"; + +import { User } from "@nirvana/core/models"; +import UserSearchResponse from "../../core/responses/userSearch.response"; +import { UserService } from "../services/user.service"; + +export default function getSearchRoutes() { + const router = express.Router(); + + router.use(express.json()); + + // get user details based on id token + router.get("/users", authCheck, handleUserSearch); + + return router; +} + +async function handleUserSearch(req: Request, res: Response) { + try { + const userInfo = res.locals.userInfo as JwtClaims; + + const { query } = req.query; + + if (!query) { + res.status(400).send("No search query provided!"); + return; + } + + // text search on users + const users: User[] | null = await UserService.getUsersLikeEmailAndName( + query as string + ); + + const filteredUsers = users?.filter( + (currUser) => currUser._id?.toString() !== userInfo.userId + ); + + const resObj = new UserSearchResponse(filteredUsers ?? []); + + res.send(resObj); + } catch (error) { + console.log(error); + res.status(500).send(`something went wrong`); + } +} diff --git a/packages/api/routes/user.ts b/packages/api/routes/user.ts new file mode 100644 index 0000000..c45f143 --- /dev/null +++ b/packages/api/routes/user.ts @@ -0,0 +1,150 @@ +import { GoogleUserInfo, User } from "@nirvana/core/models"; +import { JwtClaims, authCheck } from "../middleware/auth"; +import express, { Application, Request, Response } from "express"; + +import LoginResponse from "../../core/responses/login.response"; +import { OAuth2Client } from "google-auth-library"; +import { ObjectID } from "bson"; +import { ObjectId } from "mongodb"; +import UserDetailsResponse from "../../core/responses/userDetails.response"; +import { UserService } from "../services/user.service"; +import { UserStatus } from "../../core/models/user.model"; +import { collections } from "../services/database.service"; +import { loadConfig } from "../config"; + +const jwt = require("jsonwebtoken"); + +const config = loadConfig(); + +const client = new OAuth2Client(config.GOOGLE_AUTH_CLIENT_ID); + +export default function getUserRoutes() { + const router = express.Router(); + + router.use(express.json()); + + // get user details based on id token + router.get("/", authCheck, getUserDetails); + + router.get("/login", login); + + router.get("/authcheck", authCheck, handleAuthCheck); + + return router; +} + +async function handleAuthCheck(req: Request, res: Response) { + try { + res.status(200).json("You are good to go!"); + } catch (error) { + res.status(401).json("Unauthorized"); + } +} + +async function getUserDetails(req: Request, res: Response) { + try { + const userInfo = res.locals.userInfo as JwtClaims; + + const user = await UserService.getUserById(userInfo.userId); + + user + ? res.status(200).json(new UserDetailsResponse(user)) + : res.status(404).json("No such user"); + } catch (error) { + console.log(error); + res.status(500).json(`Problem with signing user up or logging in`); + } +} + +/** Create user if doesn't exist + * Returns jwt token for client and user details + */ +async function login(req: Request, res: Response) { + // passed in accesstoken no matter what + const { access_token, id_token } = req.query; + + try { + const ticket = await client.verifyIdToken({ + idToken: (id_token as string) ?? "", + audience: config.GOOGLE_AUTH_CLIENT_ID, // Specify the CLIENT_ID of the app that accesses the backend + }); + const googleUserId = ticket.getPayload()?.sub as string; + const email = ticket.getPayload()?.email as string; + + if (!googleUserId || !email) { + res.status(401).json("no google account found"); + return; + } + + // return user details if it passed auth middleware + let user = await UserService.getUserByEmail(email); + + // if no user found, then go ahead and create user + if (!user) { + if (!access_token) { + res.status(400).json("No access token provided"); + return; + } + + // get google user info from access token + const userInfo: GoogleUserInfo = + await UserService.getGoogleUserInfoWithAccessToken( + access_token as string + ); + + // create initial user model object + + const newUser = new User( + googleUserId, + userInfo.email, + userInfo.name, + userInfo.given_name, + userInfo.family_name, + new Date(), + userInfo.picture, + userInfo.verifiedEmail, + userInfo.locale + ); + + // create user if not exists + const insertResult = await UserService.createUserIfNotExists(newUser); + + newUser._id = insertResult?.insertedId; + + // create jwt token with new user info + const jwtToken = jwt.sign( + { + userId: newUser._id, + googleUserId: newUser.googleId, + picture: newUser.picture, + email: newUser.email, + name: newUser.name, + }, + config.JWT_TOKEN_SECRET + ); + + insertResult + ? res.status(200).json(new LoginResponse(jwtToken, newUser)) + : res.status(500).json("Failed to create account, already exists"); + + return; + } + + // create jwt token with new user info + const jwtToken = jwt.sign( + { + userId: user._id, + googleUserId: user.googleId, + picture: user.picture, + email: user.email, + name: user.name, + }, + config.JWT_TOKEN_SECRET + ); + + res.status(200).json(new LoginResponse(jwtToken, user)); + } catch (error) { + console.log(error); + res.status(500).json(`Problem with signing user up or logging in`); + } +} diff --git a/packages/api/services/database.service.ts b/packages/api/services/database.service.ts new file mode 100644 index 0000000..497f340 --- /dev/null +++ b/packages/api/services/database.service.ts @@ -0,0 +1,34 @@ +// External Dependencies +import * as mongoDB from "mongodb"; + +import { loadConfig } from "../config"; + +// Global Variables +export const collections: { + users?: mongoDB.Collection; + lines?: mongoDB.Collection; + lineMembers?: mongoDB.Collection; +} = {}; + +// Initialize Connection +const config = loadConfig(); + +export const client: mongoDB.MongoClient = new mongoDB.MongoClient( + config.MONGO_CONNECTION_STRING +); + +client.connect(); + +const db: mongoDB.Db = client.db(process.env.DB_NAME); + +const usersCollection: mongoDB.Collection = db.collection("users"); +const lineCollection: mongoDB.Collection = db.collection("lines"); +const lineMembersCollection: mongoDB.Collection = db.collection("lineMembers"); + +collections.users = usersCollection; +collections.lines = lineCollection; +collections.lineMembers = lineMembersCollection; + +console.log( + `Successfully connected to database: ${db.databaseName} and collections` +); diff --git a/packages/api/services/line.service.ts b/packages/api/services/line.service.ts new file mode 100644 index 0000000..bcfa195 --- /dev/null +++ b/packages/api/services/line.service.ts @@ -0,0 +1,148 @@ +import { + Line, + LineMember, + LineMemberState, +} from "@nirvana/core/models/line.model"; +import { client, collections } from "./database.service"; + +import NirvanaResponse from "@nirvana/core/responses/nirvanaResponse"; +import { ObjectId } from "mongodb"; + +export class LineService { + static async getLineByOtherUserId(otherUserId: ObjectId) { + // get all of the conversations for this user that have exactly two conversation members + // get all of the conversationMembers for this user + // get all of the conversations for this user + // get all of the conversations + // const query = { googleId: userId }; + // const res = await collections.users?.findOne(query); + // // exists + // if (res?._id) { + // return res as User; + // } + // return null; + } + + static async getLinesByIds(convoIds: ObjectId[]) { + const query = { _id: { $in: convoIds } }; + + const convosRes = await collections.lines?.find(query).toArray(); + + // exists + if (convosRes?.length) { + return convosRes as Line[]; + } + + return null; + } + + static async getLineMembersByUserId(userId: string) { + const query = { userId: new ObjectId(userId) }; + + const convoMembersRes = await collections.lineMembers + ?.find(query) + .toArray(); + + // exists + if (convoMembersRes?.length) { + return convoMembersRes as LineMember[]; + } + + return null; + } + + /** Get all of the members associated to the given list of lines */ + static async getLineMembersInLines(lineIds: ObjectId[]) { + const query = { lineId: { $in: lineIds } }; + + const lineMembersRes = await collections.lineMembers?.find(query).toArray(); + + // exists + if (lineMembersRes?.length) { + return lineMembersRes as LineMember[]; + } + + return null; + } + + static async createLine(line: Line, lineMembers: LineMember[]) { + const session = client.startSession(); + try { + const transactionResults = await session.withTransaction(async () => { + // todo: check if convoMembers userId's actually exist + + const insertLineRes = await collections.lines?.insertOne(line); + if (!insertLineRes?.insertedId) { + await session.abortTransaction(); + console.error("failed to create line"); + + return; + } + + const insertConvoMembersRes = await collections.lineMembers?.insertMany( + lineMembers + ); + if (!insertConvoMembersRes?.insertedCount) { + await session.abortTransaction(); + console.error("failed to create line members"); + + return; + } + + console.log("success"); + return insertConvoMembersRes; + }); + + console.log(transactionResults); + + return "success"; + + // if (transactionResults) { + // console.log("The convo was successfully created."); + // return transactionResults; + // } else { + // console.log("The convo was intentionally aborted."); + // return null; + // } + } catch (e) { + console.log( + "The transaction was aborted due to an unexpected error: " + e + ); + } finally { + await session.endSession(); + } + + return null; + } + + static async updateLineMemberState( + lineId: string, + userId: string, + newState: LineMemberState + ) { + const query = { + lineId: new ObjectId(lineId), + userId: new ObjectId(userId), + }; + const updateSet = { $set: { state: newState, lastVisitDate: new Date() } }; + + const updateRes = await collections.lineMembers?.findOneAndUpdate( + query, + updateSet + ); + + return updateRes; + } + + static async updateLineMemberVisitDate(lineId: string, userId: string) { + const query = { lineId, userId: new ObjectId(userId) }; + const updateSet = { $set: { lastVisitDate: new Date() } }; + + const updateRes = await collections.lineMembers?.findOneAndUpdate( + query, + updateSet + ); + + return updateRes; + } +} diff --git a/packages/api/services/user.service.ts b/packages/api/services/user.service.ts new file mode 100644 index 0000000..443ba9a --- /dev/null +++ b/packages/api/services/user.service.ts @@ -0,0 +1,118 @@ +import { GoogleUserInfo, User } from "@nirvana/core/models"; + +import { ObjectId } from "mongodb"; +import { UserStatus } from "../../core/models/user.model"; +import axios from "axios"; +import { collections } from "./database.service"; + +export class UserService { + static async getUserById(userId: string) { + const query = { _id: new ObjectId(userId) }; + + const res = await collections.users?.findOne(query); + + // exists + if (res?._id) { + return res as User; + } + + return null; + } + + static async getUsersByIds(userIds: ObjectId[]) { + const query = { _id: { $in: userIds } }; + + const res = await collections.users?.find(query).toArray(); + + // exists + if (res?.length) { + return res as User[]; + } + + return undefined; + } + + static async getUserByGoogleId(googleUserId: string) { + const query = { googleId: googleUserId }; + + const res = await collections.users?.findOne(query); + + // exists + if (res?._id) { + return res as User; + } + + return null; + } + + static async getUserByEmail(email: string) { + const query = { email }; + + const res = await collections.users?.findOne(query); + + // exists + if (res?._id) { + return res as User; + } + + return null; + } + + static async getUsersLikeEmailAndName(searchQuery: string) { + // based on index defined in Mongo atlas + const query = { + $search: { + index: "basic user search", + text: { + query: searchQuery, + path: { + wildcard: "*", + }, + }, + }, + }; + + // const res = await collections.users?.find(query).toArray(); + + const res = await collections.users?.aggregate([query]).toArray(); + + // exists + if (res?.length) { + return res as User[]; + } + + return null; + } + + static async createUserIfNotExists(newUser: User) { + const getUser = await this.getUserByEmail(newUser.email); + + if (!getUser) { + return await collections.users?.insertOne(newUser); + } + + // user with email exists already, don't create + return null; + } + + static async getGoogleUserInfoWithAccessToken( + accessToken: string + ): Promise { + return ( + await axios.get( + `https://www.googleapis.com/oauth2/v1/userinfo?access_token=${accessToken}` + ) + ).data; + } + + static async updateUserStatus(userGoogleId: string, newStatus: UserStatus) { + const query = { googleId: userGoogleId }; + const updateDoc = { + $set: { status: newStatus, lastUpdatedDate: new Date() }, + }; + + const resultUpdate = await collections.users?.updateOne(query, updateDoc); + + return resultUpdate; + } +} diff --git a/packages/api/sockets/index.ts b/packages/api/sockets/index.ts new file mode 100644 index 0000000..9075add --- /dev/null +++ b/packages/api/sockets/index.ts @@ -0,0 +1,215 @@ +import { + ConnectToLineRequest, + RtcAnswerSomeoneRequest, + RtcCallRequest, + RtcNewUserJoinedResponse, + RtcReceiveAnswerResponse, + ServerRequestChannels, + ServerResponseChannels, + SomeoneConnectedResponse, + SomeoneDisconnectedResponse, + SomeoneTunedResponse, + SomeoneUntunedFromLineResponse, + StartBroadcastingRequest, + StopBroadcastingRequest, + TuneToLineRequest, + UntuneFromLineRequest, + UserStartedBroadcastingResponse, + UserStoppedBroadcastingResponse, +} from '@nirvana/core/sockets/channels'; + +import GetAllSocketClients from '@nirvana/core/sockets/getAllActiveSocketClients'; +import { JwtClaims } from '../middleware/auth'; +import { LineMemberState } from '@nirvana/core/models/line.model'; +import { LineService } from '../services/line.service'; +import ReceiveSignal from '@nirvana/core/sockets/receiveSignal'; +import SendSignal from '@nirvana/core/sockets/sendSignal'; +import { UserService } from '../services/user.service'; +import { UserStatus } from '@nirvana/core/models/user.model'; +import { client } from '../services/database.service'; +import { loadConfig } from '../config'; + +const jwt = require('jsonwebtoken'); + +const config = loadConfig(); + +// NOTE: client socket connections should never have to deal with socketIds +const socketIdsToUserIds: { + [socketId: string]: string; +} = {}; +const userIdsToSocketIds: { + [userId: string]: string; +} = {}; + +export default function InitializeWs(io: any) { + console.log('initializing web sockets'); + + return io + .use(function (socket: any, next: any) { + console.log('authenticating user...'); + + try { + const { token } = socket.handshake.query; + console.log(token); + + // verify jwt token with our api secret + var decoded: JwtClaims = jwt.verify(token, config.JWT_TOKEN_SECRET); + + socket.userInfo = decoded; + + next(); + } catch (error) { + console.error(error); + next(new Error('WS Authentication Error')); + } + }) + .on('connection', function (socket: any) { + const userInfo: JwtClaims = socket.userInfo; + + socketIdsToUserIds[socket.id] = userInfo.userId.toString(); + userIdsToSocketIds[userInfo.userId.toString()] = socket.id; + + console.log(`a user connected | user Id: ${userInfo.userId} and name: ${userInfo.name}`); + + socket.on('test', () => { + console.log('asdf'); + }); + + // ?verification that user is in a particular line to be tuned into it or just generally in it? + + /** CONNECT | User wants to subscribe to live emissions of a line */ + socket.on(ServerRequestChannels.CONNECT_TO_LINE, (req: ConnectToLineRequest) => { + // add this user to the room + console.log(`${socket.id} user CONNECTED room for line ${Object.keys(socket.rooms)}`); + + const roomName = `connectedLine:${req.lineId}`; + socket.join(roomName); + + const clientUserIdsInRoom = [...(io.sockets.adapter.rooms.get(roomName) ?? [])].map( + (otherUserSocketId: string) => socketIdsToUserIds[otherUserSocketId], + ); + + io.in(roomName).emit( + ServerResponseChannels.SOMEONE_CONNECTED_TO_LINE, + new SomeoneConnectedResponse(req.lineId, userInfo.userId, clientUserIdsInRoom), + ); + }); + + /** TUNE | User tunes into the line either temporarily or toggled in */ + socket.on(ServerRequestChannels.TUNE_INTO_LINE, async (req: TuneToLineRequest) => { + console.log(`${socket.id} user TUNED into room for line ${req.lineId}`); + + const roomName = `tunedLine:${req.lineId}`; + socket.join(roomName); + + const clientUserIdsInRoom = [...(io.sockets.adapter.rooms.get(roomName) ?? [])].map( + (otherUserSocketId: string) => socketIdsToUserIds[otherUserSocketId], + ); + + // we want to notify everyone connected to the line even if they are not tuned in + const connectedLineRoomName = `connectedLine:${req.lineId}`; + + io.in(connectedLineRoomName).emit( + ServerResponseChannels.SOMEONE_TUNED_INTO_LINE, + new SomeoneTunedResponse(req.lineId, userInfo.userId, clientUserIdsInRoom), + ); + }); + + /** + * Notify all connected users when someone UNTUNES from a room + */ + socket.on(ServerRequestChannels.UNTUNE_FROM_LINE, async (req: UntuneFromLineRequest) => { + const roomName = `tunedLine:${req.lineId}`; + socket.leave(roomName); + + console.log(`${userInfo.userId} left room: ${roomName}`); + + // we want to notify everyone connected to the line even if they are not tuned in + const connectedLineRoomName = `connectedLine:${req.lineId}`; + + io.in(connectedLineRoomName).emit( + ServerResponseChannels.SOMEONE_UNTUNED_FROM_LINE, + new SomeoneUntunedFromLineResponse(req.lineId, userInfo.userId), + ); + }); + + // TODO: use same pattern as tuning and untuning and send updated fresh list of current broadcasters but using another namespace/room for broadcasters in a line + /** BROADCAST UPDATE | tell all connected, not just tuned into, that there is an update to someone broadcasting */ + socket.on(ServerRequestChannels.BROADCAST_TO_LINE, (req: StartBroadcastingRequest) => { + const roomName = `connectedLine:${req.lineId}`; + + io.in(roomName).emit( + ServerResponseChannels.SOMEONE_STARTED_BROADCASTING, + new UserStartedBroadcastingResponse(req.lineId, userInfo.userId), + ); + }); + + socket.on(ServerRequestChannels.STOP_BROADCAST_TO_LINE, (req: StopBroadcastingRequest) => { + const roomName = `connectedLine:${req.lineId}`; + + io.in(roomName).emit( + ServerResponseChannels.SOMEONE_STOPPED_BROADCASTING, + new UserStoppedBroadcastingResponse(req.lineId, userInfo.userId), + ); + }); + + socket.on(ServerRequestChannels.RTC_CALL_SOMEONE_FOR_LINE, (req: RtcCallRequest) => { + console.log('slave calling a master'); + + const userSocketId = userIdsToSocketIds[req.userToCall]; + + io.to(userSocketId).emit( + ServerResponseChannels.RTC_NEW_USER_JOINED, + new RtcNewUserJoinedResponse(userInfo.userId, req.lineId, req.simplePeerSignal), + ); + }); + + socket.on( + ServerRequestChannels.RTC_ANSWER_SOMEONE_FOR_LINE, + (req: RtcAnswerSomeoneRequest) => { + console.log('master answering slave'); + + const userSocketId = userIdsToSocketIds[req.newbieUserId]; + + io.to(userSocketId).emit( + ServerResponseChannels.RTC_RECEIVING_MASTER_ANSWER, + new RtcReceiveAnswerResponse(userInfo.userId, req.lineId, req.simplePeerSignal), + ); + }, + ); + + // tell everyone in the channel to + // socket.on(ServerRequestChannels.CREATED_CHANNEL, (req: CreatedLineRequest) => { + + // }) + + // tell all people in all my lines that I am disconnecting or untuning + // tell the tuned in folks the new list of + socket.on('disconnecting', (reason: any) => { + console.log(`someone disconnecting: ${reason}`); + + console.log(socket.rooms); + + for (const roomName of socket.rooms) { + if (roomName !== socket.id) { + const lineId = roomName.split(':')[1]; + + const roomToTell = roomName.includes('tunedLine') + ? `connectedLine:${lineId}` + : roomName; + + io.in(roomToTell).emit( + ServerResponseChannels.SOMEONE_DISCONNECTED_FROM_LINE, + new SomeoneDisconnectedResponse(lineId, userInfo.userId), + ); + } + } + }); + + // ==== DISCONNECT ==== + socket.on('disconnect', () => { + delete socketIdsToUserIds[socket.id]; + delete userIdsToSocketIds[userInfo.userId]; + }); + }); +} diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 0000000..5679481 --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,101 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Projects */ + // "incremental": true, /* Enable incremental compilation */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ + // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "resolveJsonModule": true, /* Enable importing .json files */ + // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ + // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ + // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}