├── src ├── shared │ ├── services │ │ ├── .DS_Store │ │ ├── queues │ │ │ ├── auth.queue.ts │ │ │ ├── comment.queue.ts │ │ │ ├── reaction.queue.ts │ │ │ ├── post.queue.ts │ │ │ ├── follower.queue.ts │ │ │ ├── blocked.queue.ts │ │ │ ├── notification.queue.ts │ │ │ ├── user.queue.ts │ │ │ ├── image.queue.ts │ │ │ ├── chat.queue.ts │ │ │ └── email.queue.ts │ │ ├── emails │ │ │ ├── templates │ │ │ │ ├── forgot-password │ │ │ │ │ └── forgot-password-template.ts │ │ │ │ ├── notifications │ │ │ │ │ └── notification-template.ts │ │ │ │ └── reset-password │ │ │ │ │ └── reset-password-template.ts │ │ │ └── mail.transport.ts │ │ ├── redis │ │ │ ├── base.cache.ts │ │ │ └── redis.connection.ts │ │ └── db │ │ │ ├── block-user.service.ts │ │ │ ├── auth.service.ts │ │ │ ├── image.service.ts │ │ │ ├── notification.service.ts │ │ │ └── post.service.ts │ ├── sockets │ │ ├── image.ts │ │ ├── notification.ts │ │ ├── follower.ts │ │ ├── post.ts │ │ ├── chat.ts │ │ └── user.ts │ ├── workers │ │ ├── auth.worker.ts │ │ ├── comment.worker.ts │ │ ├── email.worker.ts │ │ ├── blocked.worker.ts │ │ ├── reaction.worker.ts │ │ ├── notification.worker.ts │ │ ├── follower.worker.ts │ │ ├── post.worker.ts │ │ ├── user.worker.ts │ │ ├── image.worker.ts │ │ └── chat.worker.ts │ └── globals │ │ ├── decorators │ │ └── joi-validation.decorators.ts │ │ └── helpers │ │ ├── auth-middleware.ts │ │ ├── cloudinary-upload.ts │ │ ├── helpers.ts │ │ └── error-handler.ts ├── features │ ├── images │ │ ├── schemes │ │ │ └── images.ts │ │ ├── controllers │ │ │ ├── get-images.ts │ │ │ ├── test │ │ │ │ └── get-images.test.ts │ │ │ └── delete-image.ts │ │ ├── interfaces │ │ │ └── image.interface.ts │ │ ├── models │ │ │ └── image.schema.ts │ │ └── routes │ │ │ └── imageRoutes.ts │ ├── chat │ │ ├── interfaces │ │ │ ├── conversation.interface.ts │ │ │ └── chat.interface.ts │ │ ├── models │ │ │ ├── conversation.schema.ts │ │ │ └── chat.schema.ts │ │ ├── schemes │ │ │ └── chat.ts │ │ ├── controllers │ │ │ ├── delete-chat-message.ts │ │ │ ├── add-message-reaction.ts │ │ │ ├── update-chat-message.ts │ │ │ └── get-chat-message.ts │ │ └── routes │ │ │ └── chatRoutes.ts │ ├── auth │ │ ├── controllers │ │ │ ├── signout.ts │ │ │ ├── current-user.ts │ │ │ ├── test │ │ │ │ ├── signout.test.ts │ │ │ │ └── current-user.test.ts │ │ │ └── signin.ts │ │ ├── routes │ │ │ ├── currentRoutes.ts │ │ │ └── authRoutes.ts │ │ ├── schemes │ │ │ ├── signin.ts │ │ │ ├── password.ts │ │ │ └── signup.ts │ │ ├── interfaces │ │ │ └── auth.interface.ts │ │ └── models │ │ │ └── auth.schema.ts │ ├── followers │ │ ├── models │ │ │ └── follower.schema.ts │ │ ├── interfaces │ │ │ └── follower.interface.ts │ │ ├── routes │ │ │ └── followerRoutes.ts │ │ └── controllers │ │ │ ├── unfollow-user.ts │ │ │ ├── get-followers.ts │ │ │ └── block-user.ts │ ├── notifications │ │ ├── controllers │ │ │ ├── get-notifications.ts │ │ │ ├── update-notification.ts │ │ │ ├── delete-notification.ts │ │ │ └── test │ │ │ │ ├── get-notifications.test.ts │ │ │ │ ├── update-notification.test.ts │ │ │ │ └── delete-notification.test.ts │ │ ├── routes │ │ │ └── notificationRoutes.ts │ │ ├── interfaces │ │ │ └── notification.interface.ts │ │ └── models │ │ │ └── notification.schema.ts │ ├── comments │ │ ├── schemes │ │ │ └── comment.ts │ │ ├── models │ │ │ └── comment.schema.ts │ │ ├── interfaces │ │ │ └── comment.interface.ts │ │ ├── routes │ │ │ └── commentRoutes.ts │ │ └── controllers │ │ │ ├── add-comment.ts │ │ │ ├── get-comments.ts │ │ │ └── test │ │ │ └── add-comment.test.ts │ ├── user │ │ ├── controllers │ │ │ ├── search-user.ts │ │ │ ├── update-settings.ts │ │ │ ├── update-basic-info.ts │ │ │ ├── test │ │ │ │ ├── update-settings.test.ts │ │ │ │ └── search-user.test.ts │ │ │ └── change-password.ts │ │ ├── models │ │ │ └── user.schema.ts │ │ ├── routes │ │ │ ├── userRoutes.ts │ │ │ └── healthRoutes.ts │ │ ├── schemes │ │ │ └── info.ts │ │ └── interfaces │ │ │ └── user.interface.ts │ ├── reactions │ │ ├── models │ │ │ └── reaction.schema.ts │ │ ├── schemes │ │ │ └── reactions.ts │ │ ├── controllers │ │ │ ├── remove-reaction.ts │ │ │ ├── add-reactions.ts │ │ │ ├── test │ │ │ │ ├── remove-reaction.test.ts │ │ │ │ └── add-reactions.test.ts │ │ │ └── get-reactions.ts │ │ ├── interfaces │ │ │ └── reaction.interface.ts │ │ └── routes │ │ │ └── reactionRoutes.ts │ └── post │ │ ├── controllers │ │ ├── delete-post.ts │ │ └── test │ │ │ └── delete-post.test.ts │ │ ├── models │ │ └── post.schema.ts │ │ ├── interfaces │ │ └── post.interface.ts │ │ └── routes │ │ └── postRoutes.ts ├── setupDatabase.ts ├── mocks │ ├── image.mock.ts │ ├── notification.mock.ts │ ├── auth.mock.ts │ ├── followers.mock.ts │ └── reactions.mock.ts ├── app.ts ├── routes.ts └── seeds.ts ├── scripts ├── application_start.sh ├── before_install.sh └── after_install.sh ├── .prettierrc.json ├── deployment ├── 13-route53.tf ├── 6-igw.tf ├── 4-vpc.tf ├── 8-elastic_ips.tf ├── 2-version.tf ├── 9-nat_gateway.tf ├── userdata │ ├── delete-asg.sh │ ├── update-env-file.sh │ └── user-data.sh ├── 19-ami-data.tf ├── 16-alb_route53_alias.tf ├── 3-main.tf ├── 23-bastion_hosts.tf ├── 25-iam_code_deploy.tf ├── 20-ec2_launch_config.tf ├── 12-alb_target_group.tf ├── 24-s3.tf ├── 10-private_route_table.tf ├── 7-public_route_table.tf ├── 14-route53_certificate.tf ├── 17-iam_ec2_roles.tf ├── 21-asg.tf ├── 18-elasticache.tf ├── 5-subnets.tf ├── 26-code_deploy.tf ├── 15-alb.tf └── 22-asg_policy.tf ├── .env.development.example ├── .editorconfig ├── endpoints ├── health.http ├── notification.http ├── comments.http ├── follower.http ├── image.http ├── auth.http ├── reactions.http ├── chat.http ├── user.http └── posts.http ├── appspec.yml ├── .eslintrc.json ├── tsconfig.json └── jest.config.ts /src/shared/services/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uzochukwueddie/chatty-backend/HEAD/src/shared/services/.DS_Store -------------------------------------------------------------------------------- /scripts/application_start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd /home/ec2-user/chatty-backend 4 | sudo npm run build 5 | sudo npm start 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "bracketSpacing": true, 7 | "printWidth": 140 8 | } 9 | -------------------------------------------------------------------------------- /deployment/13-route53.tf: -------------------------------------------------------------------------------- 1 | # Get your already created hosted zone 2 | data "aws_route53_zone" "main" { 3 | name = var.main_api_server_domain 4 | private_zone = false 5 | } 6 | -------------------------------------------------------------------------------- /deployment/6-igw.tf: -------------------------------------------------------------------------------- 1 | resource "aws_internet_gateway" "main_igw" { 2 | vpc_id = aws_vpc.main.id 3 | 4 | tags = merge( 5 | local.common_tags, 6 | tomap({ "Name" = "${local.prefix}-vpc-igw" }) 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /src/features/images/schemes/images.ts: -------------------------------------------------------------------------------- 1 | import Joi, { ObjectSchema } from 'joi'; 2 | 3 | const addImageSchema: ObjectSchema = Joi.object().keys({ 4 | image: Joi.string().required() 5 | }); 6 | 7 | export { addImageSchema }; 8 | -------------------------------------------------------------------------------- /scripts/before_install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DIT="/home/ec2-user/chatty-backend" 4 | if [ -d "$DIR" ]; then 5 | cd /home/ec2-user 6 | sudo rm -rf chatty-backend 7 | else 8 | echo "Directory does not exist" 9 | fi 10 | -------------------------------------------------------------------------------- /deployment/4-vpc.tf: -------------------------------------------------------------------------------- 1 | resource "aws_vpc" "main" { 2 | cidr_block = var.vpc_cidr_block 3 | enable_dns_hostnames = true 4 | 5 | tags = merge( 6 | local.common_tags, 7 | tomap({ "Name" = "${local.prefix}" }) 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /deployment/8-elastic_ips.tf: -------------------------------------------------------------------------------- 1 | resource "aws_eip" "elastic_ip" { 2 | depends_on = [ 3 | aws_internet_gateway.main_igw 4 | ] 5 | 6 | tags = merge( 7 | local.common_tags, 8 | tomap({ "Name" = "${local.prefix}-eip" }) 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /deployment/2-version.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 1.2.0" 3 | required_providers { 4 | aws = { 5 | source = "hashicorp/aws" 6 | version = "~> 3.0" 7 | } 8 | } 9 | } 10 | 11 | provider "aws" { 12 | region = var.aws_region 13 | } 14 | -------------------------------------------------------------------------------- /src/features/chat/interfaces/conversation.interface.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document } from 'mongoose'; 2 | 3 | export interface IConversationDocument extends Document { 4 | _id: mongoose.Types.ObjectId; 5 | senderId: mongoose.Types.ObjectId; 6 | receiverId: mongoose.Types.ObjectId; 7 | } 8 | -------------------------------------------------------------------------------- /src/shared/sockets/image.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'socket.io'; 2 | 3 | let socketIOImageObject: Server; 4 | 5 | export class SocketIOImageHandler { 6 | public listen(io: Server): void { 7 | socketIOImageObject = io; 8 | } 9 | } 10 | 11 | export { socketIOImageObject }; 12 | -------------------------------------------------------------------------------- /deployment/9-nat_gateway.tf: -------------------------------------------------------------------------------- 1 | resource "aws_nat_gateway" "nat_gateway" { 2 | allocation_id = aws_eip.elastic_ip.id 3 | subnet_id = aws_subnet.public_subnet_a.id 4 | 5 | tags = merge( 6 | local.common_tags, 7 | tomap({ "Name" = "${local.prefix}-nat-gw" }) 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /deployment/userdata/delete-asg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ASG=$(aws autoscaling describe-auto-scaling-groups --no-paginate --output text --query "AutoScalingGroups[? Tags[? (Key=='Type') && Value=='$ENV_TYPE']]".AutoScalingGroupName) 4 | aws autoscaling delete-auto-scaling-group --auto-scaling-group-name $ASG --force-delete 5 | -------------------------------------------------------------------------------- /scripts/after_install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd /home/ec2-user/chatty-backend 4 | sudo rm -rf env-file.zip 5 | sudo rm -rf .env 6 | sudo rm -rf .env.develop 7 | aws s3 sync s3://chattyapp-env-files/backend/develop . 8 | unzip env-file.zip 9 | sudo cp .env.develop .env 10 | sudo pm2 delete all 11 | sudo npm install 12 | -------------------------------------------------------------------------------- /src/shared/sockets/notification.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'socket.io'; 2 | 3 | let socketIONotificationObject: Server; 4 | 5 | export class SocketIONotificationHandler { 6 | public listen(io: Server): void { 7 | socketIONotificationObject = io; 8 | } 9 | } 10 | 11 | export { socketIONotificationObject }; 12 | -------------------------------------------------------------------------------- /.env.development.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL= 2 | JWT_TOKEN=t 3 | NODE_ENV= 4 | SECRET_KEY_ONE= 5 | SECRET_KEY_TWO= 6 | CLIENT_URL= 7 | API_URL= 8 | REDIS_HOST= 9 | CLOUD_NAME= 10 | CLOUD_API_KEY= 11 | CLOUD_API_SECRET= 12 | SENDER_EMAIL= 13 | SENDER_EMAIL_PASSWORD= 14 | SENDGRID_API_KEY= 15 | SENDGRID_SENDER= 16 | EC2_URL=http://169.254.169.254/latest/meta-data/instance-id 17 | -------------------------------------------------------------------------------- /src/features/auth/controllers/signout.ts: -------------------------------------------------------------------------------- 1 | import HTTP_STATUS from 'http-status-codes'; 2 | import { Request, Response } from 'express'; 3 | 4 | export class SignOut { 5 | public async update(req: Request, res: Response): Promise { 6 | req.session = null; 7 | res.status(HTTP_STATUS.OK).json({ message: 'Logout successful', user: {}, token: '' }); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.{ts,js}] 14 | quote_type = single 15 | 16 | [*.md] 17 | max_line_length = off 18 | trim_trailing_whitespace = false 19 | 20 | -------------------------------------------------------------------------------- /endpoints/health.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:5000 2 | 3 | ### 4 | GET {{baseUrl}}/health 5 | Content-Type: application/json 6 | Accept: application/json 7 | withCredentials: true 8 | 9 | ### 10 | GET {{baseUrl}}/env 11 | Content-Type: application/json 12 | Accept: application/json 13 | withCredentials: true 14 | 15 | ### 16 | GET {{baseUrl}}/fibo/5 17 | Content-Type: application/json 18 | Accept: application/json 19 | withCredentials: true 20 | -------------------------------------------------------------------------------- /deployment/19-ami-data.tf: -------------------------------------------------------------------------------- 1 | data "aws_ami" "ec2_ami" { 2 | most_recent = true 3 | owners = ["amazon"] 4 | filter { 5 | name = "name" 6 | values = ["amzn2-ami-hvm-*-gp2"] 7 | } 8 | filter { 9 | name = "root-device-type" 10 | values = ["ebs"] 11 | } 12 | filter { 13 | name = "virtualization-type" 14 | values = ["hvm"] 15 | } 16 | filter { 17 | name = "architecture" 18 | values = ["x86_64"] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /deployment/16-alb_route53_alias.tf: -------------------------------------------------------------------------------- 1 | resource "aws_route53_record" "alb_dns_record" { 2 | zone_id = data.aws_route53_zone.main.zone_id 3 | name = var.dev_api_server_domain 4 | type = "A" 5 | 6 | alias { 7 | name = aws_alb.application_load_balancer.dns_name 8 | zone_id = aws_alb.application_load_balancer.zone_id 9 | evaluate_target_health = false 10 | } 11 | 12 | depends_on = [ 13 | aws_alb.application_load_balancer 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /appspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.0 2 | os: linux 3 | files: 4 | - source: / 5 | destination: /home/ec2-user/chatty-backend 6 | hooks: 7 | BeforeInstall: 8 | - location: scripts/before_install.sh 9 | timeout: 300 10 | runas: root 11 | AfterInstall: 12 | - location: scripts/after_install.sh 13 | timeout: 300 14 | runas: root 15 | ApplicationStart: 16 | - location: scripts/application_start.sh 17 | timeout: 300 18 | runas: root 19 | file_exists_behavior: OVERWRITE 20 | -------------------------------------------------------------------------------- /deployment/3-main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | bucket = "" # Your unique AWS S3 bucket 4 | # create a sub-folder called develop 5 | key = "develop/chatapp.tfstate" 6 | region = "" # Your AWS region 7 | encrypt = true 8 | } 9 | } 10 | 11 | locals { 12 | prefix = "${var.prefix}-${terraform.workspace}" 13 | 14 | common_tags = { 15 | Environment = terraform.workspace 16 | Project = var.project 17 | ManagedBy = "Terraform" 18 | Owner = "" # Your fullname 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /deployment/23-bastion_hosts.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "bastion_host" { 2 | ami = data.aws_ami.ec2_ami.id 3 | instance_type = var.bastion_host_type 4 | vpc_security_group_ids = [aws_security_group.bastion_host_sg.id] 5 | subnet_id = aws_subnet.public_subnet_a.id 6 | key_name = "" # Add your keyPair name here 7 | associate_public_ip_address = true 8 | tags = merge( 9 | local.common_tags, 10 | tomap({ "Name" = "${local.prefix}-bastion-host" }) 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/features/images/controllers/get-images.ts: -------------------------------------------------------------------------------- 1 | import { IFileImageDocument } from '@image/interfaces/image.interface'; 2 | import { imageService } from '@service/db/image.service'; 3 | import { Request, Response } from 'express'; 4 | import HTTP_STATUS from 'http-status-codes'; 5 | 6 | export class Get { 7 | public async images(req: Request, res: Response): Promise { 8 | const images: IFileImageDocument[] = await imageService.getImages(req.params.userId); 9 | res.status(HTTP_STATUS.OK).json({ message: 'User images', images }); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/services/queues/auth.queue.ts: -------------------------------------------------------------------------------- 1 | import { IAuthJob } from '@auth/interfaces/auth.interface'; 2 | import { BaseQueue } from '@service/queues/base.queue'; 3 | import { authWorker } from '@worker/auth.worker'; 4 | 5 | class AuthQueue extends BaseQueue { 6 | constructor() { 7 | super('auth'); 8 | this.processJob('addAuthUserToDB', 5, authWorker.addAuthUserToDB); 9 | } 10 | 11 | public addAuthUserJob(name: string, data: IAuthJob): void { 12 | this.addJob(name, data); 13 | } 14 | } 15 | 16 | export const authQueue: AuthQueue = new AuthQueue(); 17 | -------------------------------------------------------------------------------- /src/features/chat/models/conversation.schema.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Model, model, Schema } from 'mongoose'; 2 | import { IConversationDocument } from '@chat/interfaces/conversation.interface'; 3 | 4 | const conversationSchema: Schema = new Schema({ 5 | senderId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, 6 | receiverId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' } 7 | }); 8 | 9 | const ConversationModel: Model = model('Conversation', conversationSchema, 'Conversation'); 10 | export { ConversationModel }; 11 | -------------------------------------------------------------------------------- /endpoints/notification.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:5000 2 | @urlPath = api/v1 3 | 4 | ### 5 | GET {{baseUrl}}/{{urlPath}}/notifications 6 | Content-Type: application/json 7 | Accept: application/json 8 | withCredentials: true 9 | 10 | ### 11 | PUT {{baseUrl}}/{{urlPath}}/notification/ 12 | Content-Type: application/json 13 | Accept: application/json 14 | withCredentials: true 15 | 16 | {} 17 | 18 | ### 19 | DELETE {{baseUrl}}/{{urlPath}}/notification/ 20 | Content-Type: application/json 21 | Accept: application/json 22 | withCredentials: true 23 | 24 | {} 25 | -------------------------------------------------------------------------------- /src/shared/services/queues/comment.queue.ts: -------------------------------------------------------------------------------- 1 | import { ICommentJob } from '@comment/interfaces/comment.interface'; 2 | import { BaseQueue } from '@service/queues/base.queue'; 3 | import { commentWorker } from '@worker/comment.worker'; 4 | 5 | class CommentQueue extends BaseQueue { 6 | constructor() { 7 | super('comments'); 8 | this.processJob('addCommentToDB', 5, commentWorker.addCommentToDB); 9 | } 10 | 11 | public addCommentJob(name: string, data: ICommentJob): void { 12 | this.addJob(name, data); 13 | } 14 | } 15 | 16 | export const commentQueue: CommentQueue = new CommentQueue(); 17 | -------------------------------------------------------------------------------- /src/features/followers/models/follower.schema.ts: -------------------------------------------------------------------------------- 1 | import { IFollowerDocument } from '@follower/interfaces/follower.interface'; 2 | import mongoose, { model, Model, Schema } from 'mongoose'; 3 | 4 | const followerSchema: Schema = new Schema({ 5 | followerId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true }, 6 | followeeId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true }, 7 | createdAt: { type: Date, default: Date.now() } 8 | }); 9 | 10 | const FollowerModel: Model = model('Follower', followerSchema, 'Follower'); 11 | export { FollowerModel }; 12 | -------------------------------------------------------------------------------- /src/shared/sockets/follower.ts: -------------------------------------------------------------------------------- 1 | import { IFollowers } from '@follower/interfaces/follower.interface'; 2 | import { Server, Socket } from 'socket.io'; 3 | 4 | export let socketIOFollowerObject: Server; 5 | 6 | export class SocketIOFollowerHandler { 7 | private io: Server; 8 | 9 | constructor(io: Server) { 10 | this.io = io; 11 | socketIOFollowerObject = io; 12 | } 13 | 14 | public listen(): void { 15 | this.io.on('connection', (socket: Socket) => { 16 | socket.on('unfollow user', (data: IFollowers) => { 17 | this.io.emit('remove follower', data); 18 | }); 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /deployment/25-iam_code_deploy.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_role" "code_deploy_iam_role" { 2 | name = var.code_deploy_role_name 3 | assume_role_policy = jsonencode({ 4 | Version = "2012-10-17" 5 | Statement = [ 6 | { 7 | Action = "sts:AssumeRole" 8 | Effect = "Allow" 9 | Principal = { 10 | Service = "codedeploy.amazonaws.com" 11 | } 12 | } 13 | ] 14 | }) 15 | } 16 | 17 | resource "aws_iam_role_policy_attachment" "AWSCodeDeployRole" { 18 | policy_arn = "arn:aws:iam::aws:policy/service-role/AWSCodeDeployRole" 19 | role = aws_iam_role.code_deploy_iam_role.name 20 | } 21 | -------------------------------------------------------------------------------- /src/features/auth/routes/currentRoutes.ts: -------------------------------------------------------------------------------- 1 | import { CurrentUser } from '@auth/controllers/current-user'; 2 | import { authMiddleware } from '@global/helpers/auth-middleware'; 3 | import express, { Router } from 'express'; 4 | 5 | class CurrentUserRoutes { 6 | private router: Router; 7 | 8 | constructor() { 9 | this.router = express.Router(); 10 | } 11 | 12 | public routes(): Router { 13 | this.router.get('/currentuser', authMiddleware.checkAuthentication, CurrentUser.prototype.read); 14 | 15 | return this.router; 16 | } 17 | } 18 | 19 | export const currentUserRoutes: CurrentUserRoutes = new CurrentUserRoutes(); 20 | -------------------------------------------------------------------------------- /src/features/notifications/controllers/get-notifications.ts: -------------------------------------------------------------------------------- 1 | import { INotificationDocument } from '@notification/interfaces/notification.interface'; 2 | import { notificationService } from '@service/db/notification.service'; 3 | import { Request, Response } from 'express'; 4 | import HTTP_STATUS from 'http-status-codes'; 5 | 6 | export class Get { 7 | public async notifications(req: Request, res: Response): Promise { 8 | const notifications: INotificationDocument[] = await notificationService.getNotifications(req.currentUser!.userId); 9 | res.status(HTTP_STATUS.OK).json({ message: 'User notifications', notifications }); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/services/emails/templates/forgot-password/forgot-password-template.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import ejs from 'ejs'; 3 | 4 | class ForgotPasswordTemplate { 5 | public passwordResetTemplate(username: string, resetLink: string): string { 6 | return ejs.render(fs.readFileSync(__dirname + '/forgot-password-template.ejs', 'utf8'), { 7 | username, 8 | resetLink, 9 | image_url: 'https://w7.pngwing.com/pngs/120/102/png-transparent-padlock-logo-computer-icons-padlock-technic-logo-password-lock.png' 10 | }); 11 | } 12 | } 13 | 14 | export const forgotPasswordTemplate: ForgotPasswordTemplate = new ForgotPasswordTemplate(); 15 | -------------------------------------------------------------------------------- /src/features/images/interfaces/image.interface.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | export interface IFileImageDocument extends mongoose.Document { 4 | userId: mongoose.Types.ObjectId | string; 5 | bgImageVersion: string; 6 | bgImageId: string; 7 | imgId: string; 8 | imgVersion: string; 9 | createdAt: Date; 10 | } 11 | 12 | export interface IFileImageJobData { 13 | key?: string; 14 | value?: string; 15 | imgId?: string; 16 | imgVersion?: string; 17 | userId?: string; 18 | imageId?: string; 19 | } 20 | 21 | export interface IBgUploadResponse { 22 | version: string; 23 | publicId: string; 24 | public_id?: string; 25 | } 26 | -------------------------------------------------------------------------------- /src/features/comments/schemes/comment.ts: -------------------------------------------------------------------------------- 1 | import Joi, { ObjectSchema } from 'joi'; 2 | 3 | const addCommentSchema: ObjectSchema = Joi.object().keys({ 4 | userTo: Joi.string().required().messages({ 5 | 'any.required': 'userTo is a required property' 6 | }), 7 | postId: Joi.string().required().messages({ 8 | 'any.required': 'postId is a required property' 9 | }), 10 | comment: Joi.string().required().messages({ 11 | 'any.required': 'comment is a required property' 12 | }), 13 | profilePicture: Joi.string().optional().allow(null, ''), 14 | commentsCount: Joi.number().optional().allow(null, '') 15 | }); 16 | 17 | export { addCommentSchema }; 18 | -------------------------------------------------------------------------------- /src/shared/services/redis/base.cache.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from 'redis'; 2 | import Logger from 'bunyan'; 3 | import { config } from '@root/config'; 4 | 5 | export type RedisClient = ReturnType; 6 | 7 | export abstract class BaseCache { 8 | client: RedisClient; 9 | log: Logger; 10 | 11 | constructor(cacheName: string) { 12 | this.client = createClient({ url: config.REDIS_HOST }); 13 | this.log = config.createLogger(cacheName); 14 | this.cacheError(); 15 | } 16 | 17 | private cacheError(): void { 18 | this.client.on('error', (error: unknown) => { 19 | this.log.error(error); 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/features/user/controllers/search-user.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import HTTP_STATUS from 'http-status-codes'; 3 | import { Helpers } from '@global/helpers/helpers'; 4 | import { userService } from '@service/db/user.service'; 5 | import { ISearchUser } from '@user/interfaces/user.interface'; 6 | 7 | export class Search { 8 | public async user(req: Request, res: Response): Promise { 9 | const regex = new RegExp(Helpers.escapeRegex(req.params.query), 'i'); 10 | const users: ISearchUser[] = await userService.searchUsers(regex); 11 | res.status(HTTP_STATUS.OK).json({ message: 'Search results', search: users }); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/features/comments/models/comment.schema.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { model, Model, Schema } from 'mongoose'; 2 | import { ICommentDocument } from '@comment/interfaces/comment.interface'; 3 | 4 | const commentSchema: Schema = new Schema({ 5 | postId: { type: mongoose.Schema.Types.ObjectId, ref: 'Post', index: true }, 6 | comment: { type: String, default: '' }, 7 | username: { type: String }, 8 | avataColor: { type: String }, 9 | profilePicture: { type: String }, 10 | createdAt: { type: Date, default: Date.now() } 11 | }); 12 | 13 | const CommentsModel: Model = model('Comment', commentSchema, 'Comment'); 14 | export { CommentsModel }; 15 | -------------------------------------------------------------------------------- /src/shared/services/redis/redis.connection.ts: -------------------------------------------------------------------------------- 1 | import Logger from 'bunyan'; 2 | import { config } from '@root/config'; 3 | import { BaseCache } from '@service/redis/base.cache'; 4 | 5 | const log: Logger = config.createLogger('redisConnection'); 6 | 7 | class RedisConnection extends BaseCache { 8 | constructor() { 9 | super('redisConnection'); 10 | } 11 | 12 | async connect(): Promise { 13 | try { 14 | await this.client.connect(); 15 | log.info(`Redis connection: ${await this.client.ping()}`); 16 | } catch (error) { 17 | log.error(error); 18 | } 19 | } 20 | } 21 | 22 | export const redisConnection: RedisConnection = new RedisConnection(); 23 | -------------------------------------------------------------------------------- /src/shared/services/queues/reaction.queue.ts: -------------------------------------------------------------------------------- 1 | import { IReactionJob } from '@reaction/interfaces/reaction.interface'; 2 | import { BaseQueue } from '@service/queues/base.queue'; 3 | import { reactionWorker } from '@worker/reaction.worker'; 4 | 5 | class ReactionQueue extends BaseQueue { 6 | constructor() { 7 | super('reactions'); 8 | this.processJob('addReactionToDB', 5, reactionWorker.addReactionToDB); 9 | this.processJob('removeReactionFromDB', 5, reactionWorker.removeReactionFromDB); 10 | } 11 | 12 | public addReactionJob(name: string, data: IReactionJob): void { 13 | this.addJob(name, data); 14 | } 15 | } 16 | 17 | export const reactionQueue: ReactionQueue = new ReactionQueue(); 18 | -------------------------------------------------------------------------------- /src/features/images/models/image.schema.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { model, Model, Schema } from 'mongoose'; 2 | import { IFileImageDocument } from '@image/interfaces/image.interface'; 3 | 4 | const imageSchema: Schema = new Schema({ 5 | userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true }, 6 | bgImageVersion: { type: String, default: '' }, 7 | bgImageId: { type: String, default: '' }, 8 | imgVersion: { type: String, default: '' }, 9 | imgId: { type: String, default: '' }, 10 | createdAt: { type: Date, default: Date.now, index: true } 11 | }); 12 | 13 | const ImageModel: Model = model('Image', imageSchema, 'Image'); 14 | export { ImageModel }; 15 | -------------------------------------------------------------------------------- /src/shared/services/queues/post.queue.ts: -------------------------------------------------------------------------------- 1 | import { IPostJobData } from '@post/interfaces/post.interface'; 2 | import { BaseQueue } from '@service/queues/base.queue'; 3 | import { postWorker } from '@worker/post.worker'; 4 | 5 | class PostQueue extends BaseQueue { 6 | constructor() { 7 | super('posts'); 8 | this.processJob('addPostToDB', 5, postWorker.savePostToDB); 9 | this.processJob('deletePostFromDB', 5, postWorker.deletePostFromDB); 10 | this.processJob('updatePostInDB', 5, postWorker.updatePostInDB); 11 | } 12 | 13 | public addPostJob(name: string, data: IPostJobData): void { 14 | this.addJob(name, data); 15 | } 16 | } 17 | 18 | export const postQueue: PostQueue = new PostQueue(); 19 | -------------------------------------------------------------------------------- /deployment/20-ec2_launch_config.tf: -------------------------------------------------------------------------------- 1 | resource "aws_launch_configuration" "asg_launch_configuration" { 2 | name = "${local.prefix}-launch-config" 3 | image_id = data.aws_ami.ec2_ami.id 4 | instance_type = var.ec2_instance_type 5 | key_name = "" # Add your keyPair name here 6 | associate_public_ip_address = false 7 | iam_instance_profile = aws_iam_instance_profile.ec2_instance_profile.name 8 | security_groups = [aws_security_group.autoscaling_group_sg.id] 9 | user_data = filebase64("${path.module}/userdata/user-data.sh") 10 | 11 | lifecycle { 12 | create_before_destroy = true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/shared/services/queues/follower.queue.ts: -------------------------------------------------------------------------------- 1 | import { IFollowerJobData } from '@follower/interfaces/follower.interface'; 2 | import { BaseQueue } from '@service/queues/base.queue'; 3 | import { followerWorker } from '@worker/follower.worker'; 4 | 5 | class FollowerQueue extends BaseQueue { 6 | constructor() { 7 | super('followers'); 8 | this.processJob('addFollowerToDB', 5, followerWorker.addFollowerToDB); 9 | this.processJob('removeFollowerFromDB', 5, followerWorker.removeFollowerFromDB); 10 | } 11 | 12 | public addFollowerJob(name: string, data: IFollowerJobData): void { 13 | this.addJob(name, data); 14 | } 15 | } 16 | 17 | export const followerQueue: FollowerQueue = new FollowerQueue(); 18 | -------------------------------------------------------------------------------- /src/features/auth/schemes/signin.ts: -------------------------------------------------------------------------------- 1 | import Joi, { ObjectSchema } from 'joi'; 2 | 3 | const loginSchema: ObjectSchema = Joi.object().keys({ 4 | username: Joi.string().required().min(4).max(8).messages({ 5 | 'string.base': 'Username must be of type string', 6 | 'string.min': 'Invalid username', 7 | 'string.max': 'Invalid username', 8 | 'string.empty': 'Username is a required field' 9 | }), 10 | password: Joi.string().required().min(4).max(8).messages({ 11 | 'string.base': 'Password must be of type string', 12 | 'string.min': 'Invalid password', 13 | 'string.max': 'Invalid password', 14 | 'string.empty': 'Password is a required field' 15 | }) 16 | }); 17 | 18 | export { loginSchema }; 19 | -------------------------------------------------------------------------------- /src/features/notifications/controllers/update-notification.ts: -------------------------------------------------------------------------------- 1 | import { notificationQueue } from '@service/queues/notification.queue'; 2 | import { socketIONotificationObject } from '@socket/notification'; 3 | import { Request, Response } from 'express'; 4 | import HTTP_STATUS from 'http-status-codes'; 5 | 6 | export class Update { 7 | public async notification(req: Request, res: Response): Promise { 8 | const { notificationId } = req.params; 9 | socketIONotificationObject.emit('update notification', notificationId); 10 | notificationQueue.addNotificationJob('updateNotification', { key: notificationId }); 11 | res.status(HTTP_STATUS.OK).json({ message: 'Notification marked as read' }); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shared/workers/auth.worker.ts: -------------------------------------------------------------------------------- 1 | import { DoneCallback, Job } from 'bull'; 2 | import Logger from 'bunyan'; 3 | import { config } from '@root/config'; 4 | import { authService } from '@service/db/auth.service'; 5 | 6 | const log: Logger = config.createLogger('authWorker'); 7 | 8 | class AuthWorker { 9 | async addAuthUserToDB(job: Job, done: DoneCallback): Promise { 10 | try { 11 | const { value } = job.data; 12 | await authService.createAuthUser(value); 13 | job.progress(100); 14 | done(null, job.data); 15 | } catch (error) { 16 | log.error(error); 17 | done(error as Error); 18 | } 19 | } 20 | } 21 | 22 | export const authWorker: AuthWorker = new AuthWorker(); 23 | -------------------------------------------------------------------------------- /src/features/notifications/controllers/delete-notification.ts: -------------------------------------------------------------------------------- 1 | import { notificationQueue } from '@service/queues/notification.queue'; 2 | import { socketIONotificationObject } from '@socket/notification'; 3 | import { Request, Response } from 'express'; 4 | import HTTP_STATUS from 'http-status-codes'; 5 | 6 | export class Delete { 7 | public async notification(req: Request, res: Response): Promise { 8 | const { notificationId } = req.params; 9 | socketIONotificationObject.emit('delete notification', notificationId); 10 | notificationQueue.addNotificationJob('deleteNotification', { key: notificationId }); 11 | res.status(HTTP_STATUS.OK).json({ message: 'Notification deleted successfully' }); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/features/reactions/models/reaction.schema.ts: -------------------------------------------------------------------------------- 1 | import { IReactionDocument } from '@reaction/interfaces/reaction.interface'; 2 | import mongoose, { model, Model, Schema } from 'mongoose'; 3 | 4 | const reactionSchema: Schema = new Schema({ 5 | postId: { type: mongoose.Schema.Types.ObjectId, ref: 'Post', index: true }, 6 | type: { type: String, default: '' }, 7 | username: { type: String, default: '' }, 8 | avataColor: { type: String, default: '' }, 9 | profilePicture: { type: String, default: '' }, 10 | createdAt: { type: Date, default: Date.now() } 11 | }); 12 | 13 | const ReactionModel: Model = model('Reaction', reactionSchema, 'Reaction'); 14 | 15 | export { ReactionModel }; 16 | -------------------------------------------------------------------------------- /src/shared/workers/comment.worker.ts: -------------------------------------------------------------------------------- 1 | import { DoneCallback, Job } from 'bull'; 2 | import Logger from 'bunyan'; 3 | import { config } from '@root/config'; 4 | import { commentService } from '@service/db/comment.service'; 5 | 6 | const log: Logger = config.createLogger('commentWorker'); 7 | 8 | class CommentWorker { 9 | async addCommentToDB(job: Job, done: DoneCallback): Promise { 10 | try { 11 | const { data } = job; 12 | await commentService.addCommentToDB(data); 13 | job.progress(100); 14 | done(null, job.data); 15 | } catch (error) { 16 | log.error(error); 17 | done(error as Error); 18 | } 19 | } 20 | } 21 | 22 | export const commentWorker: CommentWorker = new CommentWorker(); 23 | -------------------------------------------------------------------------------- /src/shared/services/queues/blocked.queue.ts: -------------------------------------------------------------------------------- 1 | import { IBlockedUserJobData } from '@follower/interfaces/follower.interface'; 2 | import { BaseQueue } from '@service/queues/base.queue'; 3 | import { blockedUserWorker } from '@worker/blocked.worker'; 4 | 5 | class BlockedUserQueue extends BaseQueue { 6 | constructor() { 7 | super('blockedUsers'); 8 | this.processJob('addBlockedUserToDB', 5, blockedUserWorker.addBlockedUserToDB); 9 | this.processJob('removeBlockedUserFromDB', 5, blockedUserWorker.addBlockedUserToDB); 10 | } 11 | 12 | public addBlockedUserJob(name: string, data: IBlockedUserJobData): void { 13 | this.addJob(name, data); 14 | } 15 | } 16 | 17 | export const blockedUserQueue: BlockedUserQueue = new BlockedUserQueue(); 18 | -------------------------------------------------------------------------------- /src/setupDatabase.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import Logger from 'bunyan'; 3 | import { config } from '@root/config'; 4 | import { redisConnection } from '@service/redis/redis.connection'; 5 | 6 | const log: Logger = config.createLogger('setupDatabase'); 7 | 8 | export default () => { 9 | const connect = () => { 10 | mongoose 11 | .connect(`${config.DATABASE_URL}`) 12 | .then(() => { 13 | log.info('Successfully connected to database.'); 14 | redisConnection.connect(); 15 | }) 16 | .catch((error) => { 17 | log.error('Error connecting to database', error); 18 | return process.exit(1); 19 | }); 20 | }; 21 | connect(); 22 | 23 | mongoose.connection.on('disconnected', connect); 24 | }; 25 | -------------------------------------------------------------------------------- /src/shared/services/queues/notification.queue.ts: -------------------------------------------------------------------------------- 1 | import { INotificationJobData } from '@notification/interfaces/notification.interface'; 2 | import { BaseQueue } from '@service/queues/base.queue'; 3 | import { notificationWorker } from '@worker/notification.worker'; 4 | 5 | class NotificationQueue extends BaseQueue { 6 | constructor() { 7 | super('notifications'); 8 | this.processJob('updateNotification', 5, notificationWorker.updateNotification); 9 | this.processJob('deleteNotification', 5, notificationWorker.deleteNotification); 10 | } 11 | 12 | public addNotificationJob(name: string, data: INotificationJobData): void { 13 | this.addJob(name, data); 14 | } 15 | } 16 | 17 | export const notificationQueue: NotificationQueue = new NotificationQueue(); 18 | -------------------------------------------------------------------------------- /deployment/userdata/update-env-file.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function program_is_installed { 4 | local return_=1 5 | 6 | type $1 >/dev/null 2>&1 || { local return_=0; } 7 | echo "$return_" 8 | } 9 | 10 | if [ $(program_is_installed zip) == 0 ]; then 11 | apk update 12 | apk add zip 13 | fi 14 | 15 | aws s3 sync s3:///backend/develop . # update with your s3 bucket 16 | unzip env-file.zip 17 | cp .env.develop .env 18 | rm .env.develop 19 | sed -i -e "s|\(^REDIS_HOST=\).*|REDIS_HOST=redis://$ELASTICACHE_ENDPOINT:6379|g" .env 20 | rm -rf env-file.zip 21 | cp .env .env.develop 22 | zip env-file.zip .env.develop 23 | aws --region eu-central-1 s3 cp env-file.zip s3:///backend/develop/ # update with your s3 bucket 24 | rm -rf .env* 25 | rm -rf env-file.zip 26 | -------------------------------------------------------------------------------- /src/shared/workers/email.worker.ts: -------------------------------------------------------------------------------- 1 | import { DoneCallback, Job } from 'bull'; 2 | import Logger from 'bunyan'; 3 | import { config } from '@root/config'; 4 | import { mailTransport } from '@service/emails/mail.transport'; 5 | 6 | const log: Logger = config.createLogger('emailWorker'); 7 | 8 | class EmailWorker { 9 | async addNotificationEmail(job: Job, done: DoneCallback): Promise { 10 | try { 11 | const { template, receiverEmail, subject } = job.data; 12 | await mailTransport.sendEmail(receiverEmail, subject, template); 13 | job.progress(100); 14 | done(null, job.data); 15 | } catch (error) { 16 | log.error(error); 17 | done(error as Error); 18 | } 19 | } 20 | } 21 | 22 | export const emailWorker: EmailWorker = new EmailWorker(); 23 | -------------------------------------------------------------------------------- /src/shared/services/emails/templates/notifications/notification-template.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import ejs from 'ejs'; 3 | import { INotificationTemplate } from '@notification/interfaces/notification.interface'; 4 | 5 | class NotificationTemplate { 6 | public notificationMessageTemplate(templateParams: INotificationTemplate): string { 7 | const { username, header, message } = templateParams; 8 | return ejs.render(fs.readFileSync(__dirname + '/notification.ejs', 'utf8'), { 9 | username, 10 | header, 11 | message, 12 | image_url: 'https://w7.pngwing.com/pngs/120/102/png-transparent-padlock-logo-computer-icons-padlock-technic-logo-password-lock.png' 13 | }); 14 | } 15 | } 16 | 17 | export const notificationTemplate: NotificationTemplate = new NotificationTemplate(); 18 | -------------------------------------------------------------------------------- /src/shared/services/queues/user.queue.ts: -------------------------------------------------------------------------------- 1 | import { BaseQueue } from '@service/queues/base.queue'; 2 | import { IUserJob } from '@user/interfaces/user.interface'; 3 | import { userWorker } from '@worker/user.worker'; 4 | 5 | class UserQueue extends BaseQueue { 6 | constructor() { 7 | super('user'); 8 | this.processJob('addUserToDB', 5, userWorker.addUserToDB); 9 | this.processJob('updateSocialLinksInDB', 5, userWorker.updateSocialLinks); 10 | this.processJob('updateBasicInfoInDB', 5, userWorker.updateUserInfo); 11 | this.processJob('updateNotificationSettings', 5, userWorker.updateNotificationSettings); 12 | } 13 | 14 | public addUserJob(name: string, data: IUserJob): void { 15 | this.addJob(name, data); 16 | } 17 | } 18 | 19 | export const userQueue: UserQueue = new UserQueue(); 20 | -------------------------------------------------------------------------------- /src/features/chat/schemes/chat.ts: -------------------------------------------------------------------------------- 1 | import Joi, { ObjectSchema } from 'joi'; 2 | 3 | const addChatSchema: ObjectSchema = Joi.object().keys({ 4 | conversationId: Joi.string().optional().allow(null, ''), 5 | receiverId: Joi.string().required(), 6 | receiverUsername: Joi.string().required(), 7 | receiverAvatarColor: Joi.string().required(), 8 | receiverProfilePicture: Joi.string().required(), 9 | body: Joi.string().optional().allow(null, ''), 10 | gifUrl: Joi.string().optional().allow(null, ''), 11 | selectedImage: Joi.string().optional().allow(null, ''), 12 | isRead: Joi.boolean().optional() 13 | }); 14 | 15 | const markChatSchema: ObjectSchema = Joi.object().keys({ 16 | senderId: Joi.string().required(), 17 | receiverId: Joi.string().required() 18 | }); 19 | 20 | export { addChatSchema, markChatSchema }; 21 | -------------------------------------------------------------------------------- /src/shared/sockets/post.ts: -------------------------------------------------------------------------------- 1 | import { ICommentDocument } from '@comment/interfaces/comment.interface'; 2 | import { IReactionDocument } from '@reaction/interfaces/reaction.interface'; 3 | import { Server, Socket } from 'socket.io'; 4 | 5 | export let socketIOPostObject: Server; 6 | 7 | export class SocketIOPostHandler { 8 | private io: Server; 9 | 10 | constructor(io: Server) { 11 | this.io = io; 12 | socketIOPostObject = io; 13 | } 14 | 15 | public listen(): void { 16 | this.io.on('connection', (socket: Socket) => { 17 | socket.on('reaction', (reaction: IReactionDocument) => { 18 | this.io.emit('update like', reaction); 19 | }); 20 | 21 | socket.on('comment', (data: ICommentDocument) => { 22 | this.io.emit('update comment', data); 23 | }); 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/features/post/controllers/delete-post.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { PostCache } from '@service/redis/post.cache'; 3 | import HTTP_STATUS from 'http-status-codes'; 4 | import { postQueue } from '@service/queues/post.queue'; 5 | import { socketIOPostObject } from '@socket/post'; 6 | 7 | const postCache: PostCache = new PostCache(); 8 | 9 | export class Delete { 10 | public async post(req: Request, res: Response): Promise { 11 | socketIOPostObject.emit('delete post', req.params.postId); 12 | await postCache.deletePostFromCache(req.params.postId, `${req.currentUser!.userId}`); 13 | postQueue.addPostJob('deletePostFromDB', { keyOne: req.params.postId, keyTwo: req.currentUser!.userId }); 14 | res.status(HTTP_STATUS.OK).json({ message: 'Post deleted successfully' }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/shared/services/emails/templates/reset-password/reset-password-template.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import ejs from 'ejs'; 3 | import { IResetPasswordParams } from '@user/interfaces/user.interface'; 4 | 5 | class ResetPasswordTemplate { 6 | public passwordResetConfirmationTemplate(templateParams: IResetPasswordParams): string { 7 | const { username, email, ipaddress, date } = templateParams; 8 | return ejs.render(fs.readFileSync(__dirname + '/reset-password-template.ejs', 'utf8'), { 9 | username, 10 | email, 11 | ipaddress, 12 | date, 13 | image_url: 'https://w7.pngwing.com/pngs/120/102/png-transparent-padlock-logo-computer-icons-padlock-technic-logo-password-lock.png' 14 | }); 15 | } 16 | } 17 | 18 | export const resetPasswordTemplate: ResetPasswordTemplate = new ResetPasswordTemplate(); 19 | -------------------------------------------------------------------------------- /src/shared/services/queues/image.queue.ts: -------------------------------------------------------------------------------- 1 | import { IFileImageJobData } from '@image/interfaces/image.interface'; 2 | import { BaseQueue } from '@service/queues/base.queue'; 3 | import { imageWorker } from '@worker/image.worker'; 4 | 5 | class ImageQueue extends BaseQueue { 6 | constructor() { 7 | super('images'); 8 | this.processJob('addUserProfileImageToDB', 5, imageWorker.addUserProfileImageToDB); 9 | this.processJob('updateBGImageInDB', 5, imageWorker.updateBGImageInDB); 10 | this.processJob('addImageToDB', 5, imageWorker.addImageToDB); 11 | this.processJob('removeImageFromDB', 5, imageWorker.removeImageFromDB); 12 | } 13 | 14 | public addImageJob(name: string, data: IFileImageJobData): void { 15 | this.addJob(name, data); 16 | } 17 | } 18 | 19 | export const imageQueue: ImageQueue = new ImageQueue(); 20 | -------------------------------------------------------------------------------- /deployment/12-alb_target_group.tf: -------------------------------------------------------------------------------- 1 | resource "aws_alb_target_group" "server_backend_tg" { 2 | name = "${local.prefix}-tg" 3 | vpc_id = aws_vpc.main.id 4 | port = 5000 # API server port 5 | protocol = "HTTP" 6 | deregistration_delay = 60 7 | 8 | health_check { 9 | path = "/health" 10 | port = "traffic-port" 11 | protocol = "HTTP" 12 | healthy_threshold = 2 13 | unhealthy_threshold = 10 14 | interval = 120 15 | timeout = 100 16 | matcher = "200" 17 | } 18 | 19 | stickiness { 20 | type = "app_cookie" 21 | cookie_name = "session" 22 | } 23 | 24 | tags = merge( 25 | local.common_tags, 26 | tomap({ "Name" = "${local.prefix}-tg" }) 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /deployment/24-s3.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "code_deploy_backend_bucket" { 2 | bucket = "${local.prefix}-app" 3 | force_destroy = true 4 | 5 | tags = local.common_tags 6 | } 7 | 8 | resource "aws_s3_bucket_acl" "code_deploy_bucket_acl" { 9 | bucket = aws_s3_bucket.code_deploy_backend_bucket.id 10 | acl = "private" 11 | } 12 | 13 | resource "aws_s3_bucket_public_access_block" "public_block" { 14 | bucket = aws_s3_bucket.code_deploy_backend_bucket.id 15 | 16 | block_public_acls = true 17 | block_public_policy = true 18 | restrict_public_buckets = true 19 | ignore_public_acls = true 20 | } 21 | 22 | resource "aws_s3_bucket_versioning" "code_deploy_bucket_versioning" { 23 | bucket = aws_s3_bucket.code_deploy_backend_bucket.id 24 | versioning_configuration { 25 | status = "Enabled" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/shared/services/queues/chat.queue.ts: -------------------------------------------------------------------------------- 1 | import { IChatJobData, IMessageData } from '@chat/interfaces/chat.interface'; 2 | import { BaseQueue } from '@service/queues/base.queue'; 3 | import { chatWorker } from '@worker/chat.worker'; 4 | 5 | class ChatQueue extends BaseQueue { 6 | constructor() { 7 | super('chats'); 8 | this.processJob('addChatMessageToDB', 5, chatWorker.addChatMessageToDB); 9 | this.processJob('markMessageAsDeletedInDB', 5, chatWorker.markMessageAsDeleted); 10 | this.processJob('markMessagesAsReadInDB', 5, chatWorker.markMessagesAsReadInDB); 11 | this.processJob('updateMessageReaction', 5, chatWorker.updateMessageReaction); 12 | } 13 | 14 | public addChatJob(name: string, data: IChatJobData | IMessageData): void { 15 | this.addJob(name, data); 16 | } 17 | } 18 | 19 | export const chatQueue: ChatQueue = new ChatQueue(); 20 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier" 11 | ], 12 | "parserOptions": { 13 | "ecmaVersion": 2020, // Allows for the parsing of modern ECMAScript features 14 | "sourceType": "module" // Allows for the use of imports 15 | }, 16 | "rules": { 17 | "semi": [2, "always"], 18 | "space-before-function-paren": [0, {"anonymous": "always", "named": "always"}], 19 | "camelcase": 0, 20 | "no-return-assign": 0, 21 | "quotes": ["error", "single"], 22 | "@typescript-eslint/no-non-null-assertion": "off", 23 | "@typescript-eslint/no-namespace": "off", 24 | "@typescript-eslint/explicit-module-boundary-types": "off" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/features/comments/interfaces/comment.interface.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { Document } from 'mongoose'; 3 | 4 | export interface ICommentDocument extends Document { 5 | _id?: string | ObjectId; 6 | username: string; 7 | avatarColor: string; 8 | postId: string; 9 | profilePicture: string; 10 | comment: string; 11 | createdAt?: Date; 12 | userTo?: string | ObjectId; 13 | } 14 | 15 | export interface ICommentJob { 16 | postId: string; 17 | userTo: string; 18 | userFrom: string; 19 | username: string; 20 | comment: ICommentDocument; 21 | } 22 | 23 | export interface ICommentNameList { 24 | count: number; 25 | names: string[]; 26 | } 27 | 28 | export interface IQueryComment { 29 | _id?: string | ObjectId; 30 | postId?: string | ObjectId; 31 | } 32 | 33 | export interface IQuerySort { 34 | createdAt?: number; 35 | } 36 | -------------------------------------------------------------------------------- /endpoints/comments.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:5000 2 | @urlPath = api/v1 3 | 4 | ### 5 | GET {{baseUrl}}/{{urlPath}}/post/comments/ 6 | Content-Type: application/json 7 | Accept: application/json 8 | withCredentials: true 9 | 10 | ### 11 | GET {{baseUrl}}/{{urlPath}}/post/commentsnames/ 12 | Content-Type: application/json 13 | Accept: application/json 14 | withCredentials: true 15 | 16 | ### 17 | GET {{baseUrl}}/{{urlPath}}/post/single/comment// 18 | Content-Type: application/json 19 | Accept: application/json 20 | withCredentials: true 21 | 22 | ### 23 | POST {{baseUrl}}/{{urlPath}}/post/comment 24 | Content-Type: application/json 25 | Accept: application/json 26 | withCredentials: true 27 | 28 | { 29 | "userTo": "", 30 | "postId": "", 31 | "comment": "", 32 | "profilePicture": "" 33 | } 34 | -------------------------------------------------------------------------------- /src/features/reactions/schemes/reactions.ts: -------------------------------------------------------------------------------- 1 | import Joi, { ObjectSchema } from 'joi'; 2 | 3 | const addReactionSchema: ObjectSchema = Joi.object().keys({ 4 | userTo: Joi.string().required().messages({ 5 | 'any.required': 'userTo is a required property' 6 | }), 7 | postId: Joi.string().required().messages({ 8 | 'any.required': 'postId is a required property' 9 | }), 10 | type: Joi.string().required().messages({ 11 | 'any.required': 'Reaction type is a required property' 12 | }), 13 | profilePicture: Joi.string().optional().allow(null, ''), 14 | previousReaction: Joi.string().optional().allow(null, ''), 15 | postReactions: Joi.object().optional().allow(null, '') 16 | }); 17 | 18 | const removeReactionSchema: ObjectSchema = Joi.object().keys({ 19 | postReactions: Joi.object().optional().allow(null, '') 20 | }); 21 | 22 | export { addReactionSchema, removeReactionSchema }; 23 | -------------------------------------------------------------------------------- /deployment/10-private_route_table.tf: -------------------------------------------------------------------------------- 1 | resource "aws_route_table" "private_route_table" { 2 | vpc_id = aws_vpc.main.id 3 | 4 | tags = merge( 5 | local.common_tags, 6 | tomap({ "Name" = "${local.prefix}-private-RT" }) 7 | ) 8 | } 9 | 10 | resource "aws_route" "private_nat_gw_route" { 11 | route_table_id = aws_route_table.private_route_table.id 12 | destination_cidr_block = var.global_destination_cidr_block 13 | nat_gateway_id = aws_nat_gateway.nat_gateway.id 14 | } 15 | 16 | resource "aws_route_table_association" "private_subnet_1_association" { 17 | subnet_id = aws_subnet.private_subnet_a.id 18 | route_table_id = aws_route_table.private_route_table.id 19 | } 20 | 21 | resource "aws_route_table_association" "private_subnet_2_association" { 22 | subnet_id = aws_subnet.private_subnet_b.id 23 | route_table_id = aws_route_table.private_route_table.id 24 | } 25 | -------------------------------------------------------------------------------- /src/shared/sockets/chat.ts: -------------------------------------------------------------------------------- 1 | import { ISenderReceiver } from '@chat/interfaces/chat.interface'; 2 | import { Server, Socket } from 'socket.io'; 3 | import { connectedUsersMap } from './user'; 4 | 5 | export let socketIOChatObject: Server; 6 | 7 | export class SocketIOChatHandler { 8 | private io: Server; 9 | 10 | constructor(io: Server) { 11 | this.io = io; 12 | socketIOChatObject = io; 13 | } 14 | 15 | public listen(): void { 16 | this.io.on('connection', (socket: Socket) => { 17 | socket.on('join room', (users: ISenderReceiver) => { 18 | const { senderName, receiverName } = users; 19 | const senderSocketId: string = connectedUsersMap.get(senderName) as string; 20 | const receiverSocketId: string = connectedUsersMap.get(receiverName) as string; 21 | socket.join(senderSocketId); 22 | socket.join(receiverSocketId); 23 | }); 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/shared/workers/blocked.worker.ts: -------------------------------------------------------------------------------- 1 | import { DoneCallback, Job } from 'bull'; 2 | import Logger from 'bunyan'; 3 | import { config } from '@root/config'; 4 | import { blockUserService } from '@service/db/block-user.service'; 5 | 6 | const log: Logger = config.createLogger('blockedUserWorker'); 7 | 8 | class BlockedUserWorker { 9 | async addBlockedUserToDB(job: Job, done: DoneCallback): Promise { 10 | try { 11 | const { keyOne, keyTwo, type } = job.data; 12 | if (type === 'block') { 13 | await blockUserService.blockUser(keyOne, keyTwo); 14 | } else { 15 | await blockUserService.unblockUser(keyOne, keyTwo); 16 | } 17 | job.progress(100); 18 | done(null, job.data); 19 | } catch (error) { 20 | log.error(error); 21 | done(error as Error); 22 | } 23 | } 24 | } 25 | 26 | export const blockedUserWorker: BlockedUserWorker = new BlockedUserWorker(); 27 | -------------------------------------------------------------------------------- /src/features/auth/schemes/password.ts: -------------------------------------------------------------------------------- 1 | import Joi, { ObjectSchema } from 'joi'; 2 | 3 | const emailSchema: ObjectSchema = Joi.object().keys({ 4 | email: Joi.string().email().required().messages({ 5 | 'string.base': 'Field must be valid', 6 | 'string.required': 'Field must be valid', 7 | 'string.email': 'Field must be valid' 8 | }) 9 | }); 10 | 11 | const passwordSchema: ObjectSchema = Joi.object().keys({ 12 | password: Joi.string().required().min(4).max(8).messages({ 13 | 'string.base': 'Password should be of type string', 14 | 'string.min': 'Invalid password', 15 | 'string.max': 'Invalid password', 16 | 'string.empty': 'Password is a required field' 17 | }), 18 | confirmPassword: Joi.string().required().valid(Joi.ref('password')).messages({ 19 | 'any.only': 'Passwords should match', 20 | 'any.required': 'Confirm password is a required field' 21 | }) 22 | }); 23 | 24 | export { emailSchema, passwordSchema }; 25 | -------------------------------------------------------------------------------- /deployment/7-public_route_table.tf: -------------------------------------------------------------------------------- 1 | resource "aws_route_table" "public_route_table" { 2 | vpc_id = aws_vpc.main.id 3 | 4 | tags = merge( 5 | local.common_tags, 6 | tomap({ "Name" = "${local.prefix}-public-RT" }) 7 | ) 8 | } 9 | 10 | resource "aws_route" "public_igw_route" { 11 | route_table_id = aws_route_table.public_route_table.id 12 | destination_cidr_block = var.global_destination_cidr_block 13 | gateway_id = aws_internet_gateway.main_igw.id 14 | depends_on = [ 15 | aws_route_table.public_route_table 16 | ] 17 | } 18 | 19 | resource "aws_route_table_association" "public_subnet_1_association" { 20 | subnet_id = aws_subnet.public_subnet_a.id 21 | route_table_id = aws_route_table.public_route_table.id 22 | } 23 | 24 | resource "aws_route_table_association" "public_subnet_2_association" { 25 | subnet_id = aws_subnet.public_subnet_b.id 26 | route_table_id = aws_route_table.public_route_table.id 27 | } 28 | -------------------------------------------------------------------------------- /src/shared/globals/decorators/joi-validation.decorators.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { JoiRequestValidationError } from '@global/helpers/error-handler'; 3 | import { Request } from 'express'; 4 | import { ObjectSchema } from 'joi'; 5 | 6 | type IJoiDecorator = (target: any, key: string, descriptor: PropertyDescriptor) => void; 7 | 8 | export function joiValidation(schema: ObjectSchema): IJoiDecorator { 9 | return (_target: any, _key: string, descriptor: PropertyDescriptor) => { 10 | const originalMethod = descriptor.value; 11 | 12 | descriptor.value = async function (...args: any[]) { 13 | const req: Request = args[0]; 14 | const { error } = await Promise.resolve(schema.validate(req.body)); 15 | if (error?.details) { 16 | throw new JoiRequestValidationError(error.details[0].message); 17 | } 18 | return originalMethod.apply(this, args); 19 | }; 20 | return descriptor; 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/shared/services/queues/email.queue.ts: -------------------------------------------------------------------------------- 1 | import { BaseQueue } from '@service/queues/base.queue'; 2 | import { IEmailJob } from '@user/interfaces/user.interface'; 3 | import { emailWorker } from '@worker/email.worker'; 4 | 5 | class EmailQueue extends BaseQueue { 6 | constructor() { 7 | super('emails'); 8 | this.processJob('forgotPasswordEmail', 5, emailWorker.addNotificationEmail); 9 | this.processJob('commentsEmail', 5, emailWorker.addNotificationEmail); 10 | this.processJob('followersEmail', 5, emailWorker.addNotificationEmail); 11 | this.processJob('reactionsEmail', 5, emailWorker.addNotificationEmail); 12 | this.processJob('directMessageEmail', 5, emailWorker.addNotificationEmail); 13 | this.processJob('changePassword', 5, emailWorker.addNotificationEmail); 14 | } 15 | 16 | public addEmailJob(name: string, data: IEmailJob): void { 17 | this.addJob(name, data); 18 | } 19 | } 20 | 21 | export const emailQueue: EmailQueue = new EmailQueue(); 22 | -------------------------------------------------------------------------------- /src/features/auth/routes/authRoutes.ts: -------------------------------------------------------------------------------- 1 | import { Password } from '@auth/controllers/password'; 2 | import { SignIn } from '@auth/controllers/signin'; 3 | import { SignOut } from '@auth/controllers/signout'; 4 | import { SignUp } from '@auth/controllers/signup'; 5 | import express, { Router } from 'express'; 6 | 7 | class AuthRoutes { 8 | private router: Router; 9 | 10 | constructor() { 11 | this.router = express.Router(); 12 | } 13 | 14 | public routes(): Router { 15 | this.router.post('/signup', SignUp.prototype.create); 16 | this.router.post('/signin', SignIn.prototype.read); 17 | this.router.post('/forgot-password', Password.prototype.create); 18 | this.router.post('/reset-password/:token', Password.prototype.update); 19 | 20 | return this.router; 21 | } 22 | 23 | public signoutRoute(): Router { 24 | this.router.get('/signout', SignOut.prototype.update); 25 | 26 | return this.router; 27 | } 28 | } 29 | 30 | export const authRoutes: AuthRoutes = new AuthRoutes(); 31 | -------------------------------------------------------------------------------- /src/features/user/controllers/update-settings.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import HTTP_STATUS from 'http-status-codes'; 3 | import { UserCache } from '@service/redis/user.cache'; 4 | import { userQueue } from '@service/queues/user.queue'; 5 | import { joiValidation } from '@global/decorators/joi-validation.decorators'; 6 | import { notificationSettingsSchema } from '@user/schemes/info'; 7 | 8 | const userCache: UserCache = new UserCache(); 9 | 10 | export class UpdateSettings { 11 | @joiValidation(notificationSettingsSchema) 12 | public async notification(req: Request, res: Response): Promise { 13 | await userCache.updateSingleUserItemInCache(`${req.currentUser!.userId}`, 'notifications', req.body); 14 | userQueue.addUserJob('updateNotificationSettings', { 15 | key: `${req.currentUser!.userId}`, 16 | value: req.body 17 | }); 18 | res.status(HTTP_STATUS.OK).json({ message: 'Notification settings updated successfully', settings: req.body }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/features/auth/controllers/current-user.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { UserCache } from '@service/redis/user.cache'; 3 | import { IUserDocument } from '@user/interfaces/user.interface'; 4 | import { userService } from '@service/db/user.service'; 5 | import HTTP_STATUS from 'http-status-codes'; 6 | 7 | const userCache: UserCache = new UserCache(); 8 | 9 | export class CurrentUser { 10 | public async read(req: Request, res: Response): Promise { 11 | let isUser = false; 12 | let token = null; 13 | let user = null; 14 | const cachedUser: IUserDocument = (await userCache.getUserFromCache(`${req.currentUser!.userId}`)) as IUserDocument; 15 | const existingUser: IUserDocument = cachedUser ? cachedUser : await userService.getUserById(`${req.currentUser!.userId}`); 16 | if (Object.keys(existingUser).length) { 17 | isUser = true; 18 | token = req.session?.jwt; 19 | user = existingUser; 20 | } 21 | res.status(HTTP_STATUS.OK).json({ token, isUser, user }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/features/comments/routes/commentRoutes.ts: -------------------------------------------------------------------------------- 1 | import express, { Router } from 'express'; 2 | import { authMiddleware } from '@global/helpers/auth-middleware'; 3 | import { Get } from '@comment/controllers/get-comments'; 4 | import { Add } from '@comment/controllers/add-comment'; 5 | 6 | class CommentRoutes { 7 | private router: Router; 8 | 9 | constructor() { 10 | this.router = express.Router(); 11 | } 12 | 13 | public routes(): Router { 14 | this.router.get('/post/comments/:postId', authMiddleware.checkAuthentication, Get.prototype.comments); 15 | this.router.get('/post/commentsnames/:postId', authMiddleware.checkAuthentication, Get.prototype.commentsNamesFromCache); 16 | this.router.get('/post/single/comment/:postId/:commentId', authMiddleware.checkAuthentication, Get.prototype.singleComment); 17 | 18 | this.router.post('/post/comment', authMiddleware.checkAuthentication, Add.prototype.comment); 19 | 20 | return this.router; 21 | } 22 | } 23 | 24 | export const commentRoutes: CommentRoutes = new CommentRoutes(); 25 | -------------------------------------------------------------------------------- /src/features/reactions/controllers/remove-reaction.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import HTTP_STATUS from 'http-status-codes'; 3 | import { IReactionJob } from '@reaction/interfaces/reaction.interface'; 4 | import { ReactionCache } from '@service/redis/reaction.cache'; 5 | import { reactionQueue } from '@service/queues/reaction.queue'; 6 | 7 | const reactionCache: ReactionCache = new ReactionCache(); 8 | 9 | export class Remove { 10 | public async reaction(req: Request, res: Response): Promise { 11 | const { postId, previousReaction, postReactions } = req.params; 12 | await reactionCache.removePostReactionFromCache(postId, `${req.currentUser!.username}`, JSON.parse(postReactions)); 13 | const databaseReactionData: IReactionJob = { 14 | postId, 15 | username: req.currentUser!.username, 16 | previousReaction 17 | }; 18 | reactionQueue.addReactionJob('removeReactionFromDB', databaseReactionData); 19 | res.status(HTTP_STATUS.OK).json({ message: 'Reaction removed from post' }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/features/reactions/interfaces/reaction.interface.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { Document } from 'mongoose'; 3 | 4 | export interface IReactionDocument extends Document { 5 | _id?: string | ObjectId; 6 | username: string; 7 | avataColor: string; 8 | type: string; 9 | postId: string; 10 | profilePicture: string; 11 | createdAt?: Date; 12 | userTo?: string | ObjectId; 13 | comment?: string; 14 | } 15 | 16 | export interface IReactions { 17 | like: number; 18 | love: number; 19 | happy: number; 20 | wow: number; 21 | sad: number; 22 | angry: number; 23 | } 24 | 25 | export interface IReactionJob { 26 | postId: string; 27 | username: string; 28 | previousReaction: string; 29 | userTo?: string; 30 | userFrom?: string; 31 | type?: string; 32 | reactionObject?: IReactionDocument; 33 | } 34 | 35 | export interface IQueryReaction { 36 | _id?: string | ObjectId; 37 | postId?: string | ObjectId; 38 | } 39 | 40 | export interface IReaction { 41 | senderName: string; 42 | type: string; 43 | } 44 | -------------------------------------------------------------------------------- /src/features/notifications/routes/notificationRoutes.ts: -------------------------------------------------------------------------------- 1 | import express, { Router } from 'express'; 2 | import { authMiddleware } from '@global/helpers/auth-middleware'; 3 | import { Update } from '@notification/controllers/update-notification'; 4 | import { Delete } from '@notification/controllers/delete-notification'; 5 | import { Get } from '@notification/controllers/get-notifications'; 6 | 7 | class NotificationRoutes { 8 | private router: Router; 9 | 10 | constructor() { 11 | this.router = express.Router(); 12 | } 13 | 14 | public routes(): Router { 15 | this.router.get('/notifications', authMiddleware.checkAuthentication, Get.prototype.notifications); 16 | this.router.put('/notification/:notificationId', authMiddleware.checkAuthentication, Update.prototype.notification); 17 | this.router.delete('/notification/:notificationId', authMiddleware.checkAuthentication, Delete.prototype.notification); 18 | 19 | return this.router; 20 | } 21 | } 22 | 23 | export const notificationRoutes: NotificationRoutes = new NotificationRoutes(); 24 | -------------------------------------------------------------------------------- /deployment/14-route53_certificate.tf: -------------------------------------------------------------------------------- 1 | resource "aws_acm_certificate" "dev_cert" { 2 | domain_name = var.dev_api_server_domain 3 | validation_method = "DNS" 4 | 5 | tags = { 6 | "Name" = local.prefix 7 | Environment = terraform.workspace 8 | } 9 | 10 | lifecycle { 11 | create_before_destroy = true 12 | } 13 | } 14 | 15 | resource "aws_route53_record" "cert_validation_record" { 16 | allow_overwrite = false 17 | ttl = 60 18 | zone_id = data.aws_route53_zone.main.zone_id 19 | name = tolist(aws_acm_certificate.dev_cert.domain_validation_options)[0].resource_record_name 20 | records = [tolist(aws_acm_certificate.dev_cert.domain_validation_options)[0].resource_record_value] 21 | type = tolist(aws_acm_certificate.dev_cert.domain_validation_options)[0].resource_record_type 22 | } 23 | 24 | resource "aws_acm_certificate_validation" "cert_validation" { 25 | certificate_arn = aws_acm_certificate.dev_cert.arn 26 | validation_record_fqdns = [aws_route53_record.cert_validation_record.fqdn] 27 | } 28 | -------------------------------------------------------------------------------- /endpoints/follower.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:5000 2 | @urlPath = api/v1 3 | 4 | ### 5 | GET {{baseUrl}}/{{urlPath}}/user/following 6 | Content-Type: application/json 7 | Accept: application/json 8 | withCredentials: true 9 | 10 | ### 11 | GET {{baseUrl}}/{{urlPath}}/user/followers/ 12 | Content-Type: application/json 13 | Accept: application/json 14 | withCredentials: true 15 | 16 | ### 17 | PUT {{baseUrl}}/{{urlPath}}/user/follow/ 18 | Content-Type: application/json 19 | Accept: application/json 20 | withCredentials: true 21 | 22 | {} 23 | 24 | ### 25 | PUT {{baseUrl}}/{{urlPath}}/user/unfollow// 26 | Content-Type: application/json 27 | Accept: application/json 28 | withCredentials: true 29 | 30 | {} 31 | 32 | ### 33 | PUT {{baseUrl}}/{{urlPath}}/user/block/ 34 | Content-Type: application/json 35 | Accept: application/json 36 | withCredentials: true 37 | 38 | {} 39 | 40 | ### 41 | PUT {{baseUrl}}/{{urlPath}}/user/unblock/ 42 | Content-Type: application/json 43 | Accept: application/json 44 | withCredentials: true 45 | 46 | {} 47 | -------------------------------------------------------------------------------- /src/shared/workers/reaction.worker.ts: -------------------------------------------------------------------------------- 1 | import { DoneCallback, Job } from 'bull'; 2 | import Logger from 'bunyan'; 3 | import { config } from '@root/config'; 4 | import { reactionService } from '@service/db/reaction.service'; 5 | 6 | const log: Logger = config.createLogger('reactionWorker'); 7 | 8 | class ReactionWorker { 9 | async addReactionToDB(job: Job, done: DoneCallback): Promise { 10 | try { 11 | const { data } = job; 12 | await reactionService.addReactionDataToDB(data); 13 | job.progress(100); 14 | done(null, data); 15 | } catch (error) { 16 | log.error(error); 17 | done(error as Error); 18 | } 19 | } 20 | 21 | async removeReactionFromDB(job: Job, done: DoneCallback): Promise { 22 | try { 23 | const { data } = job; 24 | await reactionService.removeReactionDataFromDB(data); 25 | job.progress(100); 26 | done(null, data); 27 | } catch (error) { 28 | log.error(error); 29 | done(error as Error); 30 | } 31 | } 32 | } 33 | 34 | export const reactionWorker: ReactionWorker = new ReactionWorker(); 35 | -------------------------------------------------------------------------------- /endpoints/image.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:5000 2 | @urlPath = api/v1 3 | 4 | ### 5 | GET {{baseUrl}}/{{urlPath}}/images/ 6 | Content-Type: application/json 7 | Accept: application/json 8 | withCredentials: true 9 | 10 | ### 11 | POST {{baseUrl}}/{{urlPath}}/images/profile 12 | Content-Type: application/json 13 | Accept: application/json 14 | withCredentials: true 15 | 16 | { 17 | "image": "" 18 | } 19 | 20 | ### 21 | POST {{baseUrl}}/{{urlPath}}/images/background 22 | Content-Type: application/json 23 | Accept: application/json 24 | withCredentials: true 25 | 26 | # For this endpoint, image value is either 27 | # - a base64 encoded string for a new image or 28 | # - an already existing cloudinary image url 29 | 30 | { 31 | "image": "" 32 | } 33 | 34 | ### 35 | DELETE {{baseUrl}}/{{urlPath}}/images/ 36 | Content-Type: application/json 37 | Accept: application/json 38 | withCredentials: true 39 | 40 | ### 41 | DELETE {{baseUrl}}/{{urlPath}}/images/background/ 42 | Content-Type: application/json 43 | Accept: application/json 44 | withCredentials: true 45 | -------------------------------------------------------------------------------- /src/features/auth/controllers/test/signout.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { authMockRequest, authMockResponse } from '@root/mocks/auth.mock'; 3 | import { SignOut } from '@auth/controllers/signout'; 4 | 5 | const USERNAME = 'Manny'; 6 | const PASSWORD = 'manny1'; 7 | 8 | describe('SignOut', () => { 9 | it('should set session to null', async () => { 10 | const req: Request = authMockRequest({}, { username: USERNAME, password: PASSWORD }) as Request; 11 | const res: Response = authMockResponse(); 12 | await SignOut.prototype.update(req, res); 13 | expect(req.session).toBeNull(); 14 | }); 15 | 16 | it('should send correct json response', async () => { 17 | const req: Request = authMockRequest({}, { username: USERNAME, password: PASSWORD }) as Request; 18 | const res: Response = authMockResponse(); 19 | await SignOut.prototype.update(req, res); 20 | expect(res.status).toHaveBeenCalledWith(200); 21 | expect(res.json).toHaveBeenCalledWith({ 22 | message: 'Logout successful', 23 | user: {}, 24 | token: '' 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/shared/workers/notification.worker.ts: -------------------------------------------------------------------------------- 1 | import { DoneCallback, Job } from 'bull'; 2 | import Logger from 'bunyan'; 3 | import { config } from '@root/config'; 4 | import { notificationService } from '@service/db/notification.service'; 5 | 6 | const log: Logger = config.createLogger('notificationWorker'); 7 | 8 | class NotificationWorker { 9 | async updateNotification(job: Job, done: DoneCallback): Promise { 10 | try { 11 | const { key } = job.data; 12 | await notificationService.updateNotification(key); 13 | job.progress(100); 14 | done(null, job.data); 15 | } catch (error) { 16 | log.error(error); 17 | done(error as Error); 18 | } 19 | } 20 | 21 | async deleteNotification(job: Job, done: DoneCallback): Promise { 22 | try { 23 | const { key } = job.data; 24 | await notificationService.deleteNotification(key); 25 | job.progress(100); 26 | done(null, job.data); 27 | } catch (error) { 28 | log.error(error); 29 | done(error as Error); 30 | } 31 | } 32 | } 33 | 34 | export const notificationWorker: NotificationWorker = new NotificationWorker(); 35 | -------------------------------------------------------------------------------- /src/features/auth/schemes/signup.ts: -------------------------------------------------------------------------------- 1 | import Joi, { ObjectSchema } from 'joi'; 2 | 3 | const signupSchema: ObjectSchema = Joi.object().keys({ 4 | username: Joi.string().required().min(4).max(8).messages({ 5 | 'string.base': 'Username must be of type string', 6 | 'string.min': 'Invalid username', 7 | 'string.max': 'Invalid username', 8 | 'string.empty': 'Username is a required field' 9 | }), 10 | password: Joi.string().required().min(4).max(8).messages({ 11 | 'string.base': 'Password must be of type string', 12 | 'string.min': 'Invalid password', 13 | 'string.max': 'Invalid password', 14 | 'string.empty': 'Password is a required field' 15 | }), 16 | email: Joi.string().required().email().messages({ 17 | 'string.base': 'Email must be of type string', 18 | 'string.email': 'Email must be valid', 19 | 'string.empty': 'Email is a required field' 20 | }), 21 | avatarColor: Joi.string().required().messages({ 22 | 'any.required': 'Avatar color is required' 23 | }), 24 | avatarImage: Joi.string().required().messages({ 25 | 'any.required': 'Avatar image is required' 26 | }) 27 | }); 28 | 29 | export { signupSchema }; 30 | -------------------------------------------------------------------------------- /src/features/images/routes/imageRoutes.ts: -------------------------------------------------------------------------------- 1 | import express, { Router } from 'express'; 2 | import { authMiddleware } from '@global/helpers/auth-middleware'; 3 | import { Add } from '@image/controllers/add-image'; 4 | import { Delete } from '@image/controllers/delete-image'; 5 | import { Get } from '@image/controllers/get-images'; 6 | 7 | class ImageRoutes { 8 | private router: Router; 9 | 10 | constructor() { 11 | this.router = express.Router(); 12 | } 13 | 14 | public routes(): Router { 15 | this.router.get('/images/:userId', authMiddleware.checkAuthentication, Get.prototype.images); 16 | this.router.post('/images/profile', authMiddleware.checkAuthentication, Add.prototype.profileImage); 17 | this.router.post('/images/background', authMiddleware.checkAuthentication, Add.prototype.backgroundImage); 18 | this.router.delete('/images/:imageId', authMiddleware.checkAuthentication, Delete.prototype.image); 19 | this.router.delete('/images/background/:bgImageId', authMiddleware.checkAuthentication, Delete.prototype.backgroundImage); 20 | 21 | return this.router; 22 | } 23 | } 24 | 25 | export const imageRoutes: ImageRoutes = new ImageRoutes(); 26 | -------------------------------------------------------------------------------- /src/features/chat/controllers/delete-chat-message.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import HTTP_STATUS from 'http-status-codes'; 3 | import mongoose from 'mongoose'; 4 | import { MessageCache } from '@service/redis/message.cache'; 5 | import { IMessageData } from '@chat/interfaces/chat.interface'; 6 | import { socketIOChatObject } from '@socket/chat'; 7 | import { chatQueue } from '@service/queues/chat.queue'; 8 | 9 | const messageCache: MessageCache = new MessageCache(); 10 | 11 | export class Delete { 12 | public async markMessageAsDeleted(req: Request, res: Response): Promise { 13 | const { senderId, receiverId, messageId, type } = req.params; 14 | const updatedMessage: IMessageData = await messageCache.markMessageAsDeleted(`${senderId}`, `${receiverId}`, `${messageId}`, type); 15 | socketIOChatObject.emit('message read', updatedMessage); 16 | socketIOChatObject.emit('chat list', updatedMessage); 17 | chatQueue.addChatJob('markMessageAsDeletedInDB', { 18 | messageId: new mongoose.Types.ObjectId(messageId), 19 | type 20 | }); 21 | 22 | res.status(HTTP_STATUS.OK).json({ message: 'Message marked as deleted' }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /deployment/17-iam_ec2_roles.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_role" "ec2_iam_role" { 2 | name = var.ec2_iam_role_name 3 | assume_role_policy = jsonencode({ 4 | Version = "2012-10-17" 5 | Statement = [ 6 | { 7 | Action = "sts:AssumeRole" 8 | Effect = "Allow" 9 | Principal = { 10 | Service = ["ec2.amazonaws.com", "application-autoscaling.amazonaws.com"] 11 | } 12 | } 13 | ] 14 | }) 15 | } 16 | 17 | resource "aws_iam_role_policy" "ec2_iam_role_policy" { 18 | name = var.ec2_iam_role_policy_name 19 | role = aws_iam_role.ec2_iam_role.id 20 | policy = < ({ 9 | session: sessionData, 10 | body, 11 | params, 12 | currentUser 13 | }); 14 | 15 | export const imagesMockResponse = (): Response => { 16 | const res: Response = {} as Response; 17 | res.status = jest.fn().mockReturnValue(res); 18 | res.json = jest.fn().mockReturnValue(res); 19 | return res; 20 | }; 21 | 22 | export interface IParams { 23 | followerId?: string; 24 | userId?: string; 25 | imageId?: string; 26 | bgImageId?: string; 27 | } 28 | 29 | export const fileDocumentMock: IFileImageDocument = { 30 | userId: new mongoose.Types.ObjectId('60263f14648fed5246e322d9'), 31 | bgImageVersion: '2468', 32 | bgImageId: '12345', 33 | imgVersion: '', 34 | imgId: '', 35 | createdAt: new Date() 36 | } as IFileImageDocument; 37 | -------------------------------------------------------------------------------- /src/shared/workers/follower.worker.ts: -------------------------------------------------------------------------------- 1 | import { DoneCallback, Job } from 'bull'; 2 | import Logger from 'bunyan'; 3 | import { config } from '@root/config'; 4 | import { followerService } from '@service/db/follower.service'; 5 | 6 | const log: Logger = config.createLogger('followerWorker'); 7 | 8 | class FollowerWorker { 9 | async addFollowerToDB(job: Job, done: DoneCallback): Promise { 10 | try { 11 | const { keyOne, keyTwo, username, followerDocumentId } = job.data; 12 | await followerService.addFollowerToDB(keyOne, keyTwo, username, followerDocumentId); 13 | job.progress(100); 14 | done(null, job.data); 15 | } catch (error) { 16 | log.error(error); 17 | done(error as Error); 18 | } 19 | } 20 | 21 | async removeFollowerFromDB(job: Job, done: DoneCallback): Promise { 22 | try { 23 | const { keyOne, keyTwo } = job.data; 24 | await followerService.removeFollowerFromDB(keyOne, keyTwo); 25 | job.progress(100); 26 | done(null, job.data); 27 | } catch (error) { 28 | log.error(error); 29 | done(error as Error); 30 | } 31 | } 32 | } 33 | 34 | export const followerWorker: FollowerWorker = new FollowerWorker(); 35 | -------------------------------------------------------------------------------- /src/features/auth/interfaces/auth.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose'; 2 | import { ObjectId } from 'mongodb'; 3 | import { IUserDocument } from '@user/interfaces/user.interface'; 4 | 5 | declare global { 6 | namespace Express { 7 | interface Request { 8 | currentUser?: AuthPayload; 9 | } 10 | } 11 | } 12 | 13 | export interface AuthPayload { 14 | userId: string; 15 | uId: string; 16 | email: string; 17 | username: string; 18 | avatarColor: string; 19 | iat?: number; 20 | } 21 | 22 | export interface IAuthDocument extends Document { 23 | _id: string | ObjectId; 24 | uId: string; 25 | username: string; 26 | email: string; 27 | password?: string; 28 | avatarColor: string; 29 | createdAt: Date; 30 | passwordResetToken?: string; 31 | passwordResetExpires?: number | string; 32 | comparePassword(password: string): Promise; 33 | hashPassword(password: string): Promise; 34 | } 35 | 36 | export interface ISignUpData { 37 | _id: ObjectId; 38 | uId: string; 39 | email: string; 40 | username: string; 41 | password: string; 42 | avatarColor: string; 43 | } 44 | 45 | export interface IAuthJob { 46 | value?: string | IAuthDocument | IUserDocument; 47 | } 48 | -------------------------------------------------------------------------------- /src/features/images/controllers/test/get-images.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { authUserPayload } from '@root/mocks/auth.mock'; 3 | import { fileDocumentMock, imagesMockRequest, imagesMockResponse } from '@root/mocks/image.mock'; 4 | import { Get } from '@image/controllers/get-images'; 5 | import { imageService } from '@service/db/image.service'; 6 | 7 | jest.useFakeTimers(); 8 | 9 | describe('Get', () => { 10 | beforeEach(() => { 11 | jest.restoreAllMocks(); 12 | }); 13 | 14 | afterEach(() => { 15 | jest.clearAllMocks(); 16 | jest.clearAllTimers(); 17 | }); 18 | 19 | it('should send correct json response', async () => { 20 | const req: Request = imagesMockRequest({}, {}, authUserPayload, { imageId: '12345' }) as Request; 21 | const res: Response = imagesMockResponse(); 22 | jest.spyOn(imageService, 'getImages').mockResolvedValue([fileDocumentMock]); 23 | 24 | await Get.prototype.images(req, res); 25 | expect(imageService.getImages).toHaveBeenCalledWith(req.params.userId); 26 | expect(res.status).toHaveBeenCalledWith(200); 27 | expect(res.json).toHaveBeenCalledWith({ 28 | message: 'User images', 29 | images: [fileDocumentMock] 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/features/notifications/interfaces/notification.interface.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document } from 'mongoose'; 2 | 3 | export interface INotificationDocument extends Document { 4 | _id?: mongoose.Types.ObjectId | string; 5 | userTo: string; 6 | userFrom: string; 7 | message: string; 8 | notificationType: string; 9 | entityId: mongoose.Types.ObjectId; 10 | createdItemId: mongoose.Types.ObjectId; 11 | comment: string; 12 | reaction: string; 13 | post: string; 14 | imgId: string; 15 | imgVersion: string; 16 | gifUrl: string; 17 | read?: boolean; 18 | createdAt?: Date; 19 | insertNotification(data: INotification): Promise; 20 | } 21 | 22 | export interface INotification { 23 | userTo: string; 24 | userFrom: string; 25 | message: string; 26 | notificationType: string; 27 | entityId: mongoose.Types.ObjectId; 28 | createdItemId: mongoose.Types.ObjectId; 29 | createdAt: Date; 30 | comment: string; 31 | reaction: string; 32 | post: string; 33 | imgId: string; 34 | imgVersion: string; 35 | gifUrl: string; 36 | } 37 | 38 | export interface INotificationJobData { 39 | key?: string; 40 | } 41 | 42 | export interface INotificationTemplate { 43 | username: string; 44 | message: string; 45 | header: string; 46 | } 47 | -------------------------------------------------------------------------------- /src/features/chat/controllers/add-message-reaction.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import HTTP_STATUS from 'http-status-codes'; 3 | import mongoose from 'mongoose'; 4 | import { MessageCache } from '@service/redis/message.cache'; 5 | import { IMessageData } from '@chat/interfaces/chat.interface'; 6 | import { socketIOChatObject } from '@socket/chat'; 7 | import { chatQueue } from '@service/queues/chat.queue'; 8 | 9 | const messageCache: MessageCache = new MessageCache(); 10 | 11 | export class Message { 12 | public async reaction(req: Request, res: Response): Promise { 13 | const { conversationId, messageId, reaction, type } = req.body; 14 | const updatedMessage: IMessageData = await messageCache.updateMessageReaction( 15 | `${conversationId}`, 16 | `${messageId}`, 17 | `${reaction}`, 18 | `${req.currentUser!.username}`, 19 | type 20 | ); 21 | socketIOChatObject.emit('message reaction', updatedMessage); 22 | chatQueue.addChatJob('updateMessageReaction', { 23 | messageId: new mongoose.Types.ObjectId(messageId), 24 | senderName: req.currentUser!.username, 25 | reaction, 26 | type 27 | }); 28 | res.status(HTTP_STATUS.OK).json({ message: 'Message reaction added' }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/features/followers/interfaces/follower.interface.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import mongoose, { Document } from 'mongoose'; 3 | import { IUserDocument } from '@user/interfaces/user.interface'; 4 | 5 | export interface IFollowers { 6 | userId: string; 7 | } 8 | 9 | export interface IFollowerDocument extends Document { 10 | _id: mongoose.Types.ObjectId | string; 11 | followerId: mongoose.Types.ObjectId; 12 | followeeId: mongoose.Types.ObjectId; 13 | createdAt?: Date; 14 | } 15 | 16 | export interface IFollower { 17 | _id: mongoose.Types.ObjectId | string; 18 | followeeId?: IFollowerData; 19 | followerId?: IFollowerData; 20 | createdAt?: Date; 21 | } 22 | 23 | export interface IFollowerData { 24 | avatarColor: string; 25 | followersCount: number; 26 | followingCount: number; 27 | profilePicture: string; 28 | postCount: number; 29 | username: string; 30 | uId: string; 31 | _id?: mongoose.Types.ObjectId; 32 | userProfile?: IUserDocument; 33 | } 34 | 35 | export interface IFollowerJobData { 36 | keyOne?: string; 37 | keyTwo?: string; 38 | username?: string; 39 | followerDocumentId?: ObjectId; 40 | } 41 | 42 | export interface IBlockedUserJobData { 43 | keyOne?: string; 44 | keyTwo?: string; 45 | type?: string; 46 | } 47 | -------------------------------------------------------------------------------- /endpoints/auth.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:5000 2 | @urlPath = api/v1 3 | 4 | ### 5 | GET {{baseUrl}}/{{urlPath}}/signout 6 | Content-Type: application/json 7 | Accept: application/json 8 | withCredentials: true 9 | 10 | ### 11 | GET {{baseUrl}}/{{urlPath}}/currentuser 12 | Content-Type: application/json 13 | Accept: application/json 14 | withCredentials: true 15 | 16 | ### 17 | POST {{baseUrl}}/{{urlPath}}/signup 18 | Content-Type: application/json 19 | Accept: application/json 20 | withCredentials: true 21 | 22 | { 23 | "username": "", 24 | "password": "", 25 | "email": "", 26 | "avatarColor": "", 27 | "avatarImage": "" 28 | } 29 | 30 | ### 31 | POST {{baseUrl}}/{{urlPath}}/signin 32 | Content-Type: application/json 33 | Accept: application/json 34 | withCredentials: true 35 | 36 | { 37 | "username": "", 38 | "password": "" 39 | } 40 | 41 | ### 42 | POST {{baseUrl}}/{{urlPath}}/forgot-password 43 | Content-Type: application/json 44 | Accept: application/json 45 | withCredentials: true 46 | 47 | { 48 | "email": "" 49 | } 50 | 51 | ### 52 | POST {{baseUrl}}/{{urlPath}}/reset-password/ 53 | Content-Type: application/json 54 | Accept: application/json 55 | withCredentials: true 56 | 57 | { 58 | "password": "", 59 | "confirmPassword": "" 60 | } 61 | -------------------------------------------------------------------------------- /src/mocks/notification.mock.ts: -------------------------------------------------------------------------------- 1 | import { AuthPayload } from '@auth/interfaces/auth.interface'; 2 | import { INotificationDocument } from '@notification/interfaces/notification.interface'; 3 | import { Response } from 'express'; 4 | import { IJWT } from './auth.mock'; 5 | 6 | export const notificationMockRequest = (sessionData: IJWT, currentUser?: AuthPayload | null, params?: IParams) => ({ 7 | session: sessionData, 8 | params, 9 | currentUser 10 | }); 11 | 12 | export const notificationMockResponse = (): Response => { 13 | const res: Response = {} as Response; 14 | res.status = jest.fn().mockReturnValue(res); 15 | res.json = jest.fn().mockReturnValue(res); 16 | return res; 17 | }; 18 | 19 | export interface IParams { 20 | notificationId?: string; 21 | } 22 | 23 | export const notificationData = { 24 | _id: '60263f14648fed5446e322d9', 25 | userTo: '60263f14648fed5246e322d9', 26 | userFrom: '60263f14648fed5246e322d8', 27 | message: 'Testing the microphone', 28 | notificationType: 'comments', 29 | entityId: '60263f14638fed5246e322d9', 30 | createdItemId: '60263f14748fed5246e322d9', 31 | comment: '', 32 | reaction: '', 33 | post: '', 34 | imgId: '', 35 | imgVersion: '', 36 | gifUrl: '', 37 | read: false, 38 | createdAt: new Date() 39 | } as unknown as INotificationDocument; 40 | -------------------------------------------------------------------------------- /src/features/chat/models/chat.schema.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Model, model, Schema } from 'mongoose'; 2 | import { IMessageDocument } from '@chat/interfaces/chat.interface'; 3 | 4 | const messageSchema: Schema = new Schema({ 5 | conversationId: { type: mongoose.Schema.Types.ObjectId, ref: 'Conversation' }, 6 | senderId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, 7 | receiverId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, 8 | senderUsername: { type: String, default: '' }, 9 | senderAvatarColor: { type: String, default: '' }, 10 | senderProfilePicture: { type: String, default: '' }, 11 | receiverUsername: { type: String, default: '' }, 12 | receiverAvatarColor: { type: String, default: '' }, 13 | receiverProfilePicture: { type: String, default: '' }, 14 | body: { type: String, default: '' }, 15 | gifUrl: { type: String, default: '' }, 16 | isRead: { type: Boolean, default: false }, 17 | deleteForMe: { type: Boolean, default: false }, 18 | deleteForEveryone: { type: Boolean, default: false }, 19 | selectedImage: { type: String, default: '' }, 20 | reaction: Array, 21 | createdAt: { type: Date, default: Date.now } 22 | }); 23 | 24 | const MessageModel: Model = model('Message', messageSchema, 'Message'); 25 | export { MessageModel }; 26 | -------------------------------------------------------------------------------- /deployment/21-asg.tf: -------------------------------------------------------------------------------- 1 | resource "aws_autoscaling_group" "ec2_autoscaling_group" { 2 | name = "${local.prefix}-ASG" 3 | vpc_zone_identifier = [aws_subnet.private_subnet_a.id, aws_subnet.private_subnet_b.id] 4 | max_size = 1 5 | min_size = 1 6 | desired_capacity = 1 7 | launch_configuration = aws_launch_configuration.asg_launch_configuration.name 8 | health_check_type = "ELB" 9 | health_check_grace_period = 600 10 | default_cooldown = 150 11 | force_delete = true 12 | target_group_arns = [aws_alb_target_group.server_backend_tg.arn] 13 | enabled_metrics = [ 14 | "GroupMinSize", 15 | "GroupMaxSize", 16 | "GroupDesiredCapacity", 17 | "GroupInServiceInstances", 18 | "GroupTotalInstances" 19 | ] 20 | 21 | lifecycle { 22 | create_before_destroy = true 23 | } 24 | 25 | depends_on = [ 26 | aws_elasticache_replication_group.chatapp_redis_cluster 27 | ] 28 | 29 | tag { 30 | key = "Name" 31 | value = "EC2-ASG-${terraform.workspace}" 32 | propagate_at_launch = true 33 | } 34 | 35 | tag { 36 | key = "Type" 37 | value = "Backend-${terraform.workspace}" 38 | propagate_at_launch = true 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/features/chat/controllers/update-chat-message.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import HTTP_STATUS from 'http-status-codes'; 3 | import mongoose from 'mongoose'; 4 | import { MessageCache } from '@service/redis/message.cache'; 5 | import { IMessageData } from '@chat/interfaces/chat.interface'; 6 | import { socketIOChatObject } from '@socket/chat'; 7 | import { chatQueue } from '@service/queues/chat.queue'; 8 | import { joiValidation } from '@global/decorators/joi-validation.decorators'; 9 | import { markChatSchema } from '@chat/schemes/chat'; 10 | 11 | const messageCache: MessageCache = new MessageCache(); 12 | 13 | export class Update { 14 | @joiValidation(markChatSchema) 15 | public async message(req: Request, res: Response): Promise { 16 | const { senderId, receiverId } = req.body; 17 | const updatedMessage: IMessageData = await messageCache.updateChatMessages(`${senderId}`, `${receiverId}`); 18 | socketIOChatObject.emit('message read', updatedMessage); 19 | socketIOChatObject.emit('chat list', updatedMessage); 20 | chatQueue.addChatJob('markMessagesAsReadInDB', { 21 | senderId: new mongoose.Types.ObjectId(senderId), 22 | receiverId: new mongoose.Types.ObjectId(receiverId) 23 | }); 24 | res.status(HTTP_STATUS.OK).json({ message: 'Message marked as read' }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /deployment/18-elasticache.tf: -------------------------------------------------------------------------------- 1 | resource "aws_elasticache_subnet_group" "elasticache_subnet_group" { 2 | name = "${local.prefix}-subnet-elasticache-group" 3 | subnet_ids = [aws_subnet.private_subnet_a.id, aws_subnet.private_subnet_b.id] 4 | } 5 | 6 | resource "aws_elasticache_replication_group" "chatapp_redis_cluster" { 7 | automatic_failover_enabled = true 8 | replication_group_id = "${local.prefix}-redis" 9 | node_type = var.elasticache_node_type 10 | replication_group_description = "Redis elasticache replication group" 11 | number_cache_clusters = 2 12 | parameter_group_name = var.elasticache_parameter_group_name 13 | port = 6379 14 | multi_az_enabled = true 15 | subnet_group_name = aws_elasticache_subnet_group.elasticache_subnet_group.name 16 | security_group_ids = [aws_security_group.elasticache_sg.id] 17 | 18 | depends_on = [ 19 | aws_security_group.elasticache_sg 20 | ] 21 | 22 | provisioner "local-exec" { 23 | command = file("./userdata/update-env-file.sh") 24 | 25 | environment = { 26 | ELASTICACHE_ENDPOINT = self.primary_endpoint_address 27 | } 28 | } 29 | 30 | tags = merge( 31 | local.common_tags, 32 | tomap({ "Name" = "${local.prefix}-elasticache" }) 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /src/features/post/models/post.schema.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { model, Model, Schema } from 'mongoose'; 2 | import { IPostDocument } from '@post/interfaces/post.interface'; 3 | 4 | const postSchema: Schema = new Schema({ 5 | userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true }, 6 | username: { type: String }, 7 | email: { type: String }, 8 | avatarColor: { type: String }, 9 | profilePicture: { type: String }, 10 | post: { type: String, default: '' }, 11 | bgColor: { type: String, default: '' }, 12 | imgVersion: { type: String, default: '' }, 13 | imgId: { type: String, default: '' }, 14 | videoVersion: { type: String, default: '' }, 15 | videoId: { type: String, default: '' }, 16 | feelings: { type: String, default: '' }, 17 | gifUrl: { type: String, default: '' }, 18 | privacy: { type: String, default: '' }, 19 | commentsCount: { type: Number, default: 0 }, 20 | reactions: { 21 | like: { type: Number, default: 0 }, 22 | love: { type: Number, default: 0 }, 23 | happy: { type: Number, default: 0 }, 24 | wow: { type: Number, default: 0 }, 25 | sad: { type: Number, default: 0 }, 26 | angry: { type: Number, default: 0 } 27 | }, 28 | createdAt: { type: Date, default: Date.now } 29 | }); 30 | 31 | const PostModel: Model = model('Post', postSchema, 'Post'); 32 | 33 | export { PostModel }; 34 | -------------------------------------------------------------------------------- /src/features/reactions/routes/reactionRoutes.ts: -------------------------------------------------------------------------------- 1 | import express, { Router } from 'express'; 2 | import { authMiddleware } from '@global/helpers/auth-middleware'; 3 | import { Add } from '@reaction/controllers/add-reactions'; 4 | import { Remove } from '@reaction/controllers/remove-reaction'; 5 | import { Get } from '@reaction/controllers/get-reactions'; 6 | 7 | class ReactionRoutes { 8 | private router: Router; 9 | 10 | constructor() { 11 | this.router = express.Router(); 12 | } 13 | 14 | public routes(): Router { 15 | this.router.get('/post/reactions/:postId', authMiddleware.checkAuthentication, Get.prototype.reactions); 16 | this.router.get( 17 | '/post/single/reaction/username/:username/:postId', 18 | authMiddleware.checkAuthentication, 19 | Get.prototype.singleReactionByUsername 20 | ); 21 | this.router.get('/post/reactions/username/:username', authMiddleware.checkAuthentication, Get.prototype.reactionsByUsername); 22 | 23 | this.router.post('/post/reaction', authMiddleware.checkAuthentication, Add.prototype.reaction); 24 | 25 | this.router.delete( 26 | '/post/reaction/:postId/:previousReaction/:postReactions', 27 | authMiddleware.checkAuthentication, 28 | Remove.prototype.reaction 29 | ); 30 | 31 | return this.router; 32 | } 33 | } 34 | 35 | export const reactionRoutes: ReactionRoutes = new ReactionRoutes(); 36 | -------------------------------------------------------------------------------- /src/features/followers/routes/followerRoutes.ts: -------------------------------------------------------------------------------- 1 | import express, { Router } from 'express'; 2 | import { authMiddleware } from '@global/helpers/auth-middleware'; 3 | import { Add } from '@follower/controllers/follower-user'; 4 | import { Remove } from '@follower/controllers/unfollow-user'; 5 | import { Get } from '@follower/controllers/get-followers'; 6 | import { AddUser } from '@follower/controllers/block-user'; 7 | 8 | class FollowerRoutes { 9 | private router: Router; 10 | 11 | constructor() { 12 | this.router = express.Router(); 13 | } 14 | 15 | public routes(): Router { 16 | this.router.get('/user/following', authMiddleware.checkAuthentication, Get.prototype.userFollowing); 17 | this.router.get('/user/followers/:userId', authMiddleware.checkAuthentication, Get.prototype.userFollowers); 18 | 19 | this.router.put('/user/follow/:followerId', authMiddleware.checkAuthentication, Add.prototype.follower); 20 | this.router.put('/user/unfollow/:followeeId/:followerId', authMiddleware.checkAuthentication, Remove.prototype.follower); 21 | this.router.put('/user/block/:followerId', authMiddleware.checkAuthentication, AddUser.prototype.block); 22 | this.router.put('/user/unblock/:followerId', authMiddleware.checkAuthentication, AddUser.prototype.unblock); 23 | 24 | return this.router; 25 | } 26 | } 27 | 28 | export const followerRoutes: FollowerRoutes = new FollowerRoutes(); 29 | -------------------------------------------------------------------------------- /src/features/followers/controllers/unfollow-user.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import HTTP_STATUS from 'http-status-codes'; 3 | import { FollowerCache } from '@service/redis/follower.cache'; 4 | import { followerQueue } from '@service/queues/follower.queue'; 5 | 6 | const followerCache: FollowerCache = new FollowerCache(); 7 | 8 | export class Remove { 9 | public async follower(req: Request, res: Response): Promise { 10 | const { followeeId, followerId } = req.params; 11 | const removeFollowerFromCache: Promise = followerCache.removeFollowerFromCache( 12 | `following:${req.currentUser!.userId}`, 13 | followeeId 14 | ); 15 | const removeFolloweeFromCache: Promise = followerCache.removeFollowerFromCache(`followers:${followeeId}`, followerId); 16 | 17 | const followersCount: Promise = followerCache.updateFollowersCountInCache(`${followeeId}`, 'followersCount', -1); 18 | const followeeCount: Promise = followerCache.updateFollowersCountInCache(`${followerId}`, 'followingCount', -1); 19 | await Promise.all([removeFollowerFromCache, removeFolloweeFromCache, followersCount, followeeCount]); 20 | 21 | followerQueue.addFollowerJob('removeFollowerFromDB', { 22 | keyOne: `${followeeId}`, 23 | keyTwo: `${followerId}` 24 | }); 25 | res.status(HTTP_STATUS.OK).json({ message: 'Unfollowed user now' }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/shared/globals/helpers/cloudinary-upload.ts: -------------------------------------------------------------------------------- 1 | import cloudinary, { UploadApiResponse, UploadApiErrorResponse } from 'cloudinary'; 2 | 3 | export function uploads( 4 | file: string, 5 | public_id?: string, 6 | overwrite?: boolean, 7 | invalidate?: boolean 8 | ): Promise { 9 | return new Promise((resolve) => { 10 | cloudinary.v2.uploader.upload( 11 | file, 12 | { 13 | public_id, 14 | overwrite, 15 | invalidate 16 | }, 17 | (error: UploadApiErrorResponse | undefined, result: UploadApiResponse | undefined) => { 18 | if (error) resolve(error); 19 | resolve(result); 20 | } 21 | ); 22 | }); 23 | } 24 | 25 | export function videoUpload( 26 | file: string, 27 | public_id?: string, 28 | overwrite?: boolean, 29 | invalidate?: boolean 30 | ): Promise { 31 | return new Promise((resolve) => { 32 | cloudinary.v2.uploader.upload( 33 | file, 34 | { 35 | resource_type: 'video', 36 | chunk_size: 50000, 37 | public_id, 38 | overwrite, 39 | invalidate 40 | }, 41 | (error: UploadApiErrorResponse | undefined, result: UploadApiResponse | undefined) => { 42 | if (error) resolve(error); 43 | resolve(result); 44 | } 45 | ); 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /src/features/post/interfaces/post.interface.ts: -------------------------------------------------------------------------------- 1 | import { IReactions } from '@root/features/reactions/interfaces/reaction.interface'; 2 | import { ObjectId } from 'mongodb'; 3 | import mongoose, { Document } from 'mongoose'; 4 | 5 | export interface IPostDocument extends Document { 6 | _id?: string | mongoose.Types.ObjectId; 7 | userId: string; 8 | username: string; 9 | email: string; 10 | avatarColor: string; 11 | profilePicture: string; 12 | post: string; 13 | bgColor: string; 14 | commentsCount: number; 15 | imgVersion?: string; 16 | imgId?: string; 17 | videoId?: string; 18 | videoVersion?: string; 19 | feelings?: string; 20 | gifUrl?: string; 21 | privacy?: string; 22 | reactions?: IReactions; 23 | createdAt?: Date; 24 | } 25 | 26 | export interface IGetPostsQuery { 27 | _id?: ObjectId | string; 28 | username?: string; 29 | imgId?: string; 30 | gifUrl?: string; 31 | videoId?: string; 32 | } 33 | 34 | export interface ISavePostToCache { 35 | key: ObjectId | string; 36 | currentUserId: string; 37 | uId: string; 38 | createdPost: IPostDocument; 39 | } 40 | 41 | export interface IPostJobData { 42 | key?: string; 43 | value?: IPostDocument; 44 | keyOne?: string; 45 | keyTwo?: string; 46 | } 47 | 48 | export interface IQueryComplete { 49 | ok?: number; 50 | n?: number; 51 | } 52 | 53 | export interface IQueryDeleted { 54 | deletedCount?: number; 55 | } 56 | -------------------------------------------------------------------------------- /src/shared/workers/post.worker.ts: -------------------------------------------------------------------------------- 1 | import { Job, DoneCallback } from 'bull'; 2 | import Logger from 'bunyan'; 3 | import { config } from '@root/config'; 4 | import { postService } from '@service/db/post.service'; 5 | 6 | const log: Logger = config.createLogger('postWorker'); 7 | 8 | class PostWorker { 9 | async savePostToDB(job: Job, done: DoneCallback): Promise { 10 | try { 11 | const { key, value } = job.data; 12 | await postService.addPostToDB(key, value); 13 | job.progress(100); 14 | done(null, job.data); 15 | } catch (error) { 16 | log.error(error); 17 | done(error as Error); 18 | } 19 | } 20 | 21 | async deletePostFromDB(job: Job, done: DoneCallback): Promise { 22 | try { 23 | const { keyOne, keyTwo } = job.data; 24 | await postService.deletePost(keyOne, keyTwo); 25 | job.progress(100); 26 | done(null, job.data); 27 | } catch (error) { 28 | log.error(error); 29 | done(error as Error); 30 | } 31 | } 32 | 33 | async updatePostInDB(job: Job, done: DoneCallback): Promise { 34 | try { 35 | const { key, value } = job.data; 36 | await postService.editPost(key, value); 37 | job.progress(100); 38 | done(null, job.data); 39 | } catch (error) { 40 | log.error(error); 41 | done(error as Error); 42 | } 43 | } 44 | } 45 | 46 | export const postWorker: PostWorker = new PostWorker(); 47 | -------------------------------------------------------------------------------- /src/features/user/controllers/update-basic-info.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import HTTP_STATUS from 'http-status-codes'; 3 | import { UserCache } from '@service/redis/user.cache'; 4 | import { userQueue } from '@service/queues/user.queue'; 5 | import { joiValidation } from '@global/decorators/joi-validation.decorators'; 6 | import { basicInfoSchema, socialLinksSchema } from '@user/schemes/info'; 7 | 8 | const userCache: UserCache = new UserCache(); 9 | 10 | export class Edit { 11 | @joiValidation(basicInfoSchema) 12 | public async info(req: Request, res: Response): Promise { 13 | for (const [key, value] of Object.entries(req.body)) { 14 | await userCache.updateSingleUserItemInCache(`${req.currentUser!.userId}`, key, `${value}`); 15 | } 16 | userQueue.addUserJob('updateBasicInfoInDB', { 17 | key: `${req.currentUser!.userId}`, 18 | value: req.body 19 | }); 20 | res.status(HTTP_STATUS.OK).json({ message: 'Updated successfully' }); 21 | } 22 | 23 | @joiValidation(socialLinksSchema) 24 | public async social(req: Request, res: Response): Promise { 25 | await userCache.updateSingleUserItemInCache(`${req.currentUser!.userId}`, 'social', req.body); 26 | userQueue.addUserJob('updateSocialLinksInDB', { 27 | key: `${req.currentUser!.userId}`, 28 | value: req.body 29 | }); 30 | res.status(HTTP_STATUS.OK).json({ message: 'Updated successfully' }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /deployment/5-subnets.tf: -------------------------------------------------------------------------------- 1 | # Public subnets 2 | resource "aws_subnet" "public_subnet_a" { 3 | vpc_id = aws_vpc.main.id 4 | cidr_block = var.vpc_public_subnets[0] 5 | availability_zone = var.vpc_availability_zones[0] 6 | map_public_ip_on_launch = true 7 | 8 | tags = merge( 9 | local.common_tags, 10 | tomap({ "Name" = "${local.prefix}-public-1a" }) 11 | ) 12 | } 13 | 14 | resource "aws_subnet" "public_subnet_b" { 15 | vpc_id = aws_vpc.main.id 16 | cidr_block = var.vpc_public_subnets[1] 17 | availability_zone = var.vpc_availability_zones[1] 18 | map_public_ip_on_launch = true 19 | 20 | tags = merge( 21 | local.common_tags, 22 | tomap({ "Name" = "${local.prefix}-public-1b" }) 23 | ) 24 | } 25 | 26 | # Private subnets 27 | resource "aws_subnet" "private_subnet_a" { 28 | vpc_id = aws_vpc.main.id 29 | cidr_block = var.vpc_private_subnets[0] 30 | availability_zone = var.vpc_availability_zones[0] 31 | 32 | tags = merge( 33 | local.common_tags, 34 | tomap({ "Name" = "${local.prefix}-private-1a" }) 35 | ) 36 | } 37 | 38 | resource "aws_subnet" "private_subnet_b" { 39 | vpc_id = aws_vpc.main.id 40 | cidr_block = var.vpc_private_subnets[1] 41 | availability_zone = var.vpc_availability_zones[1] 42 | 43 | tags = merge( 44 | local.common_tags, 45 | tomap({ "Name" = "${local.prefix}-private-1b" }) 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/features/notifications/controllers/test/get-notifications.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { authUserPayload } from '@root/mocks/auth.mock'; 3 | import { notificationData, notificationMockRequest, notificationMockResponse } from '@root/mocks/notification.mock'; 4 | import { Get } from '@notification/controllers/get-notifications'; 5 | import { notificationService } from '@service/db/notification.service'; 6 | 7 | jest.useFakeTimers(); 8 | jest.mock('@service/queues/base.queue'); 9 | jest.mock('@service/db/notification.service'); 10 | 11 | describe('Get', () => { 12 | beforeEach(() => { 13 | jest.restoreAllMocks(); 14 | }); 15 | 16 | afterEach(() => { 17 | jest.clearAllMocks(); 18 | jest.clearAllTimers(); 19 | }); 20 | 21 | it('should send correct json response', async () => { 22 | const req: Request = notificationMockRequest({}, authUserPayload, { notificationId: '12345' }) as Request; 23 | const res: Response = notificationMockResponse(); 24 | jest.spyOn(notificationService, 'getNotifications').mockResolvedValue([notificationData]); 25 | 26 | await Get.prototype.notifications(req, res); 27 | expect(notificationService.getNotifications).toHaveBeenCalledWith(req.currentUser!.userId); 28 | expect(res.status).toHaveBeenCalledWith(200); 29 | expect(res.json).toHaveBeenCalledWith({ 30 | message: 'User notifications', 31 | notifications: [notificationData] 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /deployment/userdata/user-data.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function program_is_installed { 4 | local return_=1 5 | 6 | type $1 >/dev/null 2>&1 || { local return_=0; } 7 | echo "$return_" 8 | } 9 | 10 | sudo yum update -y 11 | sudo yum install ruby -y 12 | sudo yum install wget -y 13 | cd /home/ec2-user 14 | wget https://aws-codedeploy-eu-central-1.s3.eu-central-1.amazonaws.com/latest/install 15 | sudo chmod +x ./install 16 | sudo ./install auto 17 | 18 | # Check if NodeJs is installed. If not, install it 19 | if [ $(program_is_installed node) == 0 ]; then 20 | curl -fsSL https://rpm.nodesource.com/setup_lts.x | sudo bash - 21 | sudo yum install -y nodejs 22 | fi 23 | 24 | if [ $(program_is_installed git) == 0 ]; then 25 | sudo yum install git -y 26 | fi 27 | 28 | if [ $(program_is_installed docker) == 0 ]; then 29 | sudo amazon-linux-extras install docker -y 30 | sudo systemctl start docker 31 | sudo docker run --name chatapp-redis -p 6379:6379 --restart always --detach redis 32 | fi 33 | 34 | if [ $(program_is_installed pm2) == 0 ]; then 35 | npm install -g pm2 36 | fi 37 | 38 | cd /home/ec2-user 39 | 40 | git clone -b develop https://github.com/uzochukwueddie/chatty-backend.git # replace this github url with your url of your own project 41 | cd chatty-backend # set your project name 42 | npm install 43 | aws s3 sync s3:///backend/develop . # update with your s3 bucket 44 | unzip env-file.zip 45 | cp .env.develop .env 46 | npm run build 47 | npm run start 48 | -------------------------------------------------------------------------------- /endpoints/reactions.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:5000 2 | @urlPath = api/v1 3 | 4 | ### 5 | GET {{baseUrl}}/{{urlPath}}/post/reactions/ 6 | Content-Type: application/json 7 | Accept: application/json 8 | withCredentials: true 9 | 10 | ### 11 | GET {{baseUrl}}/{{urlPath}}/post/single/reaction/username// 12 | Content-Type: application/json 13 | Accept: application/json 14 | withCredentials: true 15 | 16 | ### 17 | GET {{baseUrl}}/{{urlPath}}/post/reactions/username/ 18 | Content-Type: application/json 19 | Accept: application/json 20 | withCredentials: true 21 | 22 | ### 23 | POST {{baseUrl}}/{{urlPath}}/post/reaction 24 | Content-Type: application/json 25 | Accept: application/json 26 | withCredentials: true 27 | 28 | # type - like | love | wow | happy | sad | angry 29 | # previousReaction (if it exist) - like | love | wow | happy | sad | angry 30 | # postReactions - increment new reaction by 1 and decrement previous reaction by 1 31 | 32 | { 33 | "userTo": "", 34 | "postId": "", 35 | "type": "", 36 | "previousReaction": "", 37 | "postReactions": {"like": 0,"love": 0,"happy": 0,"sad": 0,"wow": 0,"angry": 0}, 38 | "profilePicture": "" 39 | } 40 | 41 | ### 42 | DELETE {{baseUrl}}/{{urlPath}}/post/reaction///{"like": 0,"love": 0,"happy": 0,"sad": 0,"wow": 0,"angry": 0} 43 | Content-Type: application/json 44 | Accept: application/json 45 | withCredentials: true 46 | 47 | {} 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/features/auth/models/auth.schema.ts: -------------------------------------------------------------------------------- 1 | import { hash, compare } from 'bcryptjs'; 2 | import { IAuthDocument } from '@auth/interfaces/auth.interface'; 3 | import { model, Model, Schema } from 'mongoose'; 4 | 5 | const SALT_ROUND = 10; 6 | 7 | const authSchema: Schema = new Schema( 8 | { 9 | username: { type: String }, 10 | uId: { type: String }, 11 | email: { type: String }, 12 | password: { type: String }, 13 | avatarColor: { type: String }, 14 | createdAt: { type: Date, default: Date.now }, 15 | passwordResetToken: { type: String, default: '' }, 16 | passwordResetExpires: { type: Number } 17 | }, 18 | { 19 | toJSON: { 20 | transform(_doc, ret) { 21 | delete ret.password; 22 | return ret; 23 | } 24 | } 25 | } 26 | ); 27 | 28 | authSchema.pre('save', async function (this: IAuthDocument, next: () => void) { 29 | const hashedPassword: string = await hash(this.password as string, SALT_ROUND); 30 | this.password = hashedPassword; 31 | next(); 32 | }); 33 | 34 | authSchema.methods.comparePassword = async function (password: string): Promise { 35 | const hashedPassword: string = (this as unknown as IAuthDocument).password!; 36 | return compare(password, hashedPassword); 37 | }; 38 | 39 | authSchema.methods.hashPassword = async function (password: string): Promise { 40 | return hash(password, SALT_ROUND); 41 | }; 42 | 43 | const AuthModel: Model = model('Auth', authSchema, 'Auth'); 44 | export { AuthModel }; 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "commonjs", 5 | "lib": ["DOM", "ES2015"], 6 | "baseUrl": ".", 7 | "outDir": "./build", 8 | "rootDir": ".", 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "noUnusedLocals": false, 12 | "noUnusedParameters": false, 13 | "moduleResolution": "node", 14 | "esModuleInterop": true, 15 | "sourceMap": true, 16 | "experimentalDecorators": true, 17 | "emitDecoratorMetadata": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "allowSyntheticDefaultImports": true, 20 | "pretty": true, 21 | "resolveJsonModule": true, 22 | "plugins": [ 23 | { "transform": "typescript-transform-paths" }, 24 | { "transform": "typescript-transform-paths", "afterDeclarations": true }, 25 | ], 26 | "paths": { 27 | "@auth/*": ["src/features/auth/*"], 28 | "@user/*": ["src/features/user/*"], 29 | "@post/*": ["src/features/post/*"], 30 | "@reaction/*": ["src/features/reactions/*"], 31 | "@comment/*": ["src/features/comments/*"], 32 | "@follower/*": ["src/features/followers/*"], 33 | "@notification/*": ["src/features/notifications/*"], 34 | "@image/*": ["src/features/images/*"], 35 | "@chat/*": ["src/features/chat/*"], 36 | "@global/*": ["src/shared/globals/*"], 37 | "@service/*": ["src/shared/services/*"], 38 | "@socket/*": ["src/shared/sockets/*"], 39 | "@worker/*": ["src/shared/workers/*"], 40 | "@root/*": ["src/*"] 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/features/followers/controllers/get-followers.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { ObjectId } from 'mongodb'; 3 | import HTTP_STATUS from 'http-status-codes'; 4 | import mongoose from 'mongoose'; 5 | import { FollowerCache } from '@service/redis/follower.cache'; 6 | import { IFollowerData } from '@follower/interfaces/follower.interface'; 7 | import { followerService } from '@service/db/follower.service'; 8 | 9 | const followerCache: FollowerCache = new FollowerCache(); 10 | 11 | export class Get { 12 | public async userFollowing(req: Request, res: Response): Promise { 13 | const userObjectId: ObjectId = new mongoose.Types.ObjectId(req.currentUser!.userId); 14 | const cachedFollowees: IFollowerData[] = await followerCache.getFollowersFromCache(`following:${req.currentUser!.userId}`); 15 | const following: IFollowerData[] = cachedFollowees.length ? cachedFollowees : await followerService.getFolloweeData(userObjectId); 16 | res.status(HTTP_STATUS.OK).json({ message: 'User following', following }); 17 | } 18 | 19 | public async userFollowers(req: Request, res: Response): Promise { 20 | const userObjectId: ObjectId = new mongoose.Types.ObjectId(req.params.userId); 21 | const cachedFollowers: IFollowerData[] = await followerCache.getFollowersFromCache(`followers:${req.params.userId}`); 22 | const followers: IFollowerData[] = cachedFollowers.length ? cachedFollowers : await followerService.getFollowerData(userObjectId); 23 | res.status(HTTP_STATUS.OK).json({ message: 'User followers', followers }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/features/user/models/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { IUserDocument } from '@user/interfaces/user.interface'; 2 | import mongoose, { model, Model, Schema } from 'mongoose'; 3 | 4 | const userSchema: Schema = new Schema({ 5 | authId: { type: mongoose.Schema.Types.ObjectId, ref: 'Auth', index: true }, 6 | profilePicture: { type: String, default: '' }, 7 | postsCount: { type: Number, default: 0 }, 8 | followersCount: { type: Number, default: 0 }, 9 | followingCount: { type: Number, default: 0 }, 10 | passwordResetToken: { type: String, default: '' }, 11 | passwordResetExpires: { type: Number }, 12 | blocked: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }], 13 | blockedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }], 14 | notifications: { 15 | messages: { type: Boolean, default: true }, 16 | reactions: { type: Boolean, default: true }, 17 | comments: { type: Boolean, default: true }, 18 | follows: { type: Boolean, default: true } 19 | }, 20 | social: { 21 | facebook: { type: String, default: '' }, 22 | instagram: { type: String, default: '' }, 23 | twitter: { type: String, default: '' }, 24 | youtube: { type: String, default: '' } 25 | }, 26 | work: { type: String, default: '' }, 27 | school: { type: String, default: '' }, 28 | location: { type: String, default: '' }, 29 | quote: { type: String, default: '' }, 30 | bgImageVersion: { type: String, default: '' }, 31 | bgImageId: { type: String, default: '' } 32 | }); 33 | 34 | const UserModel: Model = model('User', userSchema, 'User'); 35 | export { UserModel }; 36 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | 3 | const config: Config.InitialOptions = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | verbose: true, 7 | coverageDirectory: 'coverage', 8 | collectCoverage: true, 9 | testPathIgnorePatterns: ['/node_modules/'], 10 | transform: { 11 | '^.+\\.ts?$': 'ts-jest' 12 | }, 13 | testMatch: ['/src/**/test/*.ts'], 14 | collectCoverageFrom: ['src/**/*.ts', '!src/**/test/*.ts?(x)', '!**/node_modules/**'], 15 | coverageThreshold: { 16 | global: { 17 | branches: 1, 18 | functions: 1, 19 | lines: 1, 20 | statements: 1 21 | } 22 | }, 23 | coverageReporters: ['text-summary', 'lcov'], 24 | moduleNameMapper: { 25 | '@auth/(.*)': ['/src/features/auth/$1'], 26 | '@user/(.*)': ['/src/features/user/$1'], 27 | '@post/(.*)': ['/src/features/post/$1'], 28 | '@reaction/(.*)': ['/src/features/reactions/$1'], 29 | '@comment/(.*)': ['/src/features/comments/$1'], 30 | '@follower/(.*)': ['/src/features/followers/$1'], 31 | '@notification/(.*)': ['/src/features/notifications/$1'], 32 | '@image/(.*)': ['/src/features/images/$1'], 33 | '@chat/(.*)': ['/src/features/chat/$1'], 34 | '@global/(.*)': ['/src/shared/globals/$1'], 35 | '@service/(.*)': ['/src/shared/services/$1'], 36 | '@socket/(.*)': ['/src/shared/sockets/$1'], 37 | '@worker/(.*)': ['/src/shared/workers/$1'], 38 | '@root/(.*)': ['/src/$1'], 39 | } 40 | }; 41 | 42 | export default config; 43 | -------------------------------------------------------------------------------- /deployment/26-code_deploy.tf: -------------------------------------------------------------------------------- 1 | resource "aws_codedeploy_app" "code_deploy_app" { 2 | name = "${local.prefix}-app" 3 | compute_platform = "Server" 4 | } 5 | 6 | resource "aws_codedeploy_deployment_group" "code_deploy_app_group" { 7 | app_name = aws_codedeploy_app.code_deploy_app.name 8 | deployment_group_name = "${local.prefix}-group" 9 | deployment_config_name = "CodeDeployDefault.AllAtOnce" 10 | service_role_arn = aws_iam_role.code_deploy_iam_role.arn 11 | autoscaling_groups = [aws_autoscaling_group.ec2_autoscaling_group.name] 12 | 13 | deployment_style { 14 | deployment_option = "WITH_TRAFFIC_CONTROL" 15 | deployment_type = "BLUE_GREEN" 16 | } 17 | 18 | load_balancer_info { 19 | target_group_info { 20 | name = aws_alb_target_group.server_backend_tg.name 21 | } 22 | } 23 | 24 | auto_rollback_configuration { 25 | enabled = true 26 | events = ["DEPLOYMENT_FAILURE"] 27 | } 28 | 29 | blue_green_deployment_config { 30 | deployment_ready_option { 31 | action_on_timeout = "CONTINUE_DEPLOYMENT" 32 | } 33 | 34 | green_fleet_provisioning_option { 35 | action = "COPY_AUTO_SCALING_GROUP" 36 | } 37 | 38 | terminate_blue_instances_on_deployment_success { 39 | action = "TERMINATE" 40 | termination_wait_time_in_minutes = 0 41 | } 42 | } 43 | 44 | provisioner "local-exec" { 45 | command = file("./userdata/delete-asg.sh") 46 | when = destroy 47 | on_failure = continue 48 | 49 | environment = { 50 | ENV_TYPE = "Backend-${terraform.workspace}" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/features/comments/controllers/add-comment.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { ObjectId } from 'mongodb'; 3 | import HTTP_STATUS from 'http-status-codes'; 4 | import { joiValidation } from '@global/decorators/joi-validation.decorators'; 5 | import { addCommentSchema } from '@comment/schemes/comment'; 6 | import { ICommentDocument, ICommentJob } from '@comment/interfaces/comment.interface'; 7 | import { CommentCache } from '@service/redis/comment.cache'; 8 | import { commentQueue } from '@service/queues/comment.queue'; 9 | 10 | const commentCache: CommentCache = new CommentCache(); 11 | 12 | export class Add { 13 | @joiValidation(addCommentSchema) 14 | public async comment(req: Request, res: Response): Promise { 15 | const { userTo, postId, profilePicture, comment } = req.body; 16 | const commentObjectId: ObjectId = new ObjectId(); 17 | const commentData: ICommentDocument = { 18 | _id: commentObjectId, 19 | postId, 20 | username: `${req.currentUser?.username}`, 21 | avatarColor: `${req.currentUser?.avatarColor}`, 22 | profilePicture, 23 | comment, 24 | createdAt: new Date() 25 | } as ICommentDocument; 26 | await commentCache.savePostCommentToCache(postId, JSON.stringify(commentData)); 27 | 28 | const databaseCommentData: ICommentJob = { 29 | postId, 30 | userTo, 31 | userFrom: req.currentUser!.userId, 32 | username: req.currentUser!.username, 33 | comment: commentData 34 | }; 35 | commentQueue.addCommentJob('addCommentToDB', databaseCommentData); 36 | res.status(HTTP_STATUS.OK).json({ message: 'Comment created successfully' }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/features/reactions/controllers/add-reactions.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { ObjectId } from 'mongodb'; 3 | import HTTP_STATUS from 'http-status-codes'; 4 | import { joiValidation } from '@global/decorators/joi-validation.decorators'; 5 | import { addReactionSchema } from '@reaction/schemes/reactions'; 6 | import { IReactionDocument, IReactionJob } from '@reaction/interfaces/reaction.interface'; 7 | import { ReactionCache } from '@service/redis/reaction.cache'; 8 | import { reactionQueue } from '@service/queues/reaction.queue'; 9 | 10 | const reactionCache: ReactionCache = new ReactionCache(); 11 | 12 | export class Add { 13 | @joiValidation(addReactionSchema) 14 | public async reaction(req: Request, res: Response): Promise { 15 | const { userTo, postId, type, previousReaction, postReactions, profilePicture } = req.body; 16 | const reactionObject: IReactionDocument = { 17 | _id: new ObjectId(), 18 | postId, 19 | type, 20 | avataColor: req.currentUser!.avatarColor, 21 | username: req.currentUser!.username, 22 | profilePicture 23 | } as IReactionDocument; 24 | 25 | await reactionCache.savePostReactionToCache(postId, reactionObject, postReactions, type, previousReaction); 26 | 27 | const databaseReactionData: IReactionJob = { 28 | postId, 29 | userTo, 30 | userFrom: req.currentUser!.userId, 31 | username: req.currentUser!.username, 32 | type, 33 | previousReaction, 34 | reactionObject 35 | }; 36 | reactionQueue.addReactionJob('addReactionToDB', databaseReactionData); 37 | res.status(HTTP_STATUS.OK).json({ message: 'Reaction added successfully' }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/shared/globals/helpers/helpers.ts: -------------------------------------------------------------------------------- 1 | export class Helpers { 2 | static firstLetterUppercase(str: string): string { 3 | const valueString = str.toLowerCase(); 4 | return valueString 5 | .split(' ') 6 | .map((value: string) => `${value.charAt(0).toUpperCase()}${value.slice(1).toLowerCase()}`) 7 | .join(' '); 8 | } 9 | 10 | static lowerCase(str: string): string { 11 | return str.toLowerCase(); 12 | } 13 | 14 | static generateRandomIntegers(integerLength: number): number { 15 | const characters = '0123456789'; 16 | let result = ' '; 17 | const charactersLength = characters.length; 18 | for (let i = 0; i < integerLength; i++) { 19 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 20 | } 21 | return parseInt(result, 10); 22 | } 23 | 24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 | static parseJson(prop: string): any { 26 | try { 27 | JSON.parse(prop); 28 | } catch (error) { 29 | return prop; 30 | } 31 | return JSON.parse(prop); 32 | } 33 | 34 | static isDataURL(value: string): boolean { 35 | const dataUrlRegex = /^\s*data:([a-z]+\/[a-z0-9-+.]+(;[a-z-]+=[a-z0-9-]+)?)?(;base64)?,([a-z0-9!$&',()*+;=\-._~:@\\/?%\s]*)\s*$/i; 36 | return dataUrlRegex.test(value); 37 | } 38 | 39 | static shuffle(list: string[]): string[] { 40 | for (let i = list.length - 1; i > 0; i--) { 41 | const j = Math.floor(Math.random() * (i + 1)); 42 | [list[i], list[j]] = [list[j], list[i]]; 43 | } 44 | return list; 45 | } 46 | 47 | static escapeRegex(text: string): string { 48 | return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/features/chat/controllers/get-chat-message.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import HTTP_STATUS from 'http-status-codes'; 3 | import mongoose from 'mongoose'; 4 | import { MessageCache } from '@service/redis/message.cache'; 5 | import { chatService } from '@service/db/chat.service'; 6 | import { IMessageData } from '@chat/interfaces/chat.interface'; 7 | 8 | const messageCache: MessageCache = new MessageCache(); 9 | 10 | export class Get { 11 | public async conversationList(req: Request, res: Response): Promise { 12 | let list: IMessageData[] = []; 13 | const cachedList: IMessageData[] = await messageCache.getUserConversationList(`${req.currentUser!.userId}`); 14 | if (cachedList.length) { 15 | list = cachedList; 16 | } else { 17 | list = await chatService.getUserConversationList(new mongoose.Types.ObjectId(req.currentUser!.userId)); 18 | } 19 | 20 | res.status(HTTP_STATUS.OK).json({ message: 'User conversation list', list }); 21 | } 22 | 23 | public async messages(req: Request, res: Response): Promise { 24 | const { receiverId } = req.params; 25 | 26 | let messages: IMessageData[] = []; 27 | const cachedMessages: IMessageData[] = await messageCache.getChatMessagesFromCache(`${req.currentUser!.userId}`, `${receiverId}`); 28 | if (cachedMessages.length) { 29 | messages = cachedMessages; 30 | } else { 31 | messages = await chatService.getMessages( 32 | new mongoose.Types.ObjectId(req.currentUser!.userId), 33 | new mongoose.Types.ObjectId(receiverId), 34 | { createdAt: 1 } 35 | ); 36 | } 37 | 38 | res.status(HTTP_STATUS.OK).json({ message: 'User chat messages', messages }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/features/post/routes/postRoutes.ts: -------------------------------------------------------------------------------- 1 | import express, { Router } from 'express'; 2 | import { authMiddleware } from '@global/helpers/auth-middleware'; 3 | import { Create } from '@post/controllers/create-post'; 4 | import { Get } from '@post/controllers/get-posts'; 5 | import { Delete } from '@post/controllers/delete-post'; 6 | import { Update } from '@post/controllers/update-post'; 7 | 8 | class PostRoutes { 9 | private router: Router; 10 | 11 | constructor() { 12 | this.router = express.Router(); 13 | } 14 | 15 | public routes(): Router { 16 | this.router.get('/post/all/:page', authMiddleware.checkAuthentication, Get.prototype.posts); 17 | this.router.get('/post/images/:page', authMiddleware.checkAuthentication, Get.prototype.postsWithImages); 18 | this.router.get('/post/videos/:page', authMiddleware.checkAuthentication, Get.prototype.postsWithVideos); 19 | 20 | this.router.post('/post', authMiddleware.checkAuthentication, Create.prototype.post); 21 | this.router.post('/post/image/post', authMiddleware.checkAuthentication, Create.prototype.postWithImage); 22 | this.router.post('/post/video/post', authMiddleware.checkAuthentication, Create.prototype.postWithVideo); 23 | 24 | this.router.put('/post/:postId', authMiddleware.checkAuthentication, Update.prototype.posts); 25 | this.router.put('/post/image/:postId', authMiddleware.checkAuthentication, Update.prototype.postWithImage); 26 | this.router.put('/post/video/:postId', authMiddleware.checkAuthentication, Update.prototype.postWithVideo); 27 | 28 | this.router.delete('/post/:postId', authMiddleware.checkAuthentication, Delete.prototype.post); 29 | 30 | return this.router; 31 | } 32 | } 33 | 34 | export const postRoutes: PostRoutes = new PostRoutes(); 35 | -------------------------------------------------------------------------------- /src/features/followers/controllers/block-user.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import HTTP_STATUS from 'http-status-codes'; 3 | import { FollowerCache } from '@service/redis/follower.cache'; 4 | import { blockedUserQueue } from '@service/queues/blocked.queue'; 5 | 6 | const followerCache: FollowerCache = new FollowerCache(); 7 | 8 | export class AddUser { 9 | public async block(req: Request, res: Response): Promise { 10 | const { followerId } = req.params; 11 | AddUser.prototype.updateBlockedUser(followerId, req.currentUser!.userId, 'block'); 12 | blockedUserQueue.addBlockedUserJob('addBlockedUserToDB', { 13 | keyOne: `${req.currentUser!.userId}`, 14 | keyTwo: `${followerId}`, 15 | type: 'block' 16 | }); 17 | res.status(HTTP_STATUS.OK).json({ message: 'User blocked' }); 18 | } 19 | 20 | public async unblock(req: Request, res: Response): Promise { 21 | const { followerId } = req.params; 22 | AddUser.prototype.updateBlockedUser(followerId, req.currentUser!.userId, 'unblock'); 23 | blockedUserQueue.addBlockedUserJob('removeBlockedUserFromDB', { 24 | keyOne: `${req.currentUser!.userId}`, 25 | keyTwo: `${followerId}`, 26 | type: 'unblock' 27 | }); 28 | res.status(HTTP_STATUS.OK).json({ message: 'User unblocked' }); 29 | } 30 | 31 | private async updateBlockedUser(followerId: string, userId: string, type: 'block' | 'unblock'): Promise { 32 | const blocked: Promise = followerCache.updateBlockedUserPropInCache(`${userId}`, 'blocked', `${followerId}`, type); 33 | const blockedBy: Promise = followerCache.updateBlockedUserPropInCache(`${followerId}`, 'blockedBy', `${userId}`, type); 34 | await Promise.all([blocked, blockedBy]); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/features/chat/routes/chatRoutes.ts: -------------------------------------------------------------------------------- 1 | import express, { Router } from 'express'; 2 | import { authMiddleware } from '@global/helpers/auth-middleware'; 3 | import { Add } from '@chat/controllers/add-chat-message'; 4 | import { Get } from '@chat/controllers/get-chat-message'; 5 | import { Delete } from '@chat/controllers/delete-chat-message'; 6 | import { Update } from '@chat/controllers/update-chat-message'; 7 | import { Message } from '@chat/controllers/add-message-reaction'; 8 | 9 | class ChatRoutes { 10 | private router: Router; 11 | 12 | constructor() { 13 | this.router = express.Router(); 14 | } 15 | 16 | public routes(): Router { 17 | this.router.get('/chat/message/conversation-list', authMiddleware.checkAuthentication, Get.prototype.conversationList); 18 | this.router.get('/chat/message/user/:receiverId', authMiddleware.checkAuthentication, Get.prototype.messages); 19 | this.router.post('/chat/message', authMiddleware.checkAuthentication, Add.prototype.message); 20 | this.router.post('/chat/message/add-chat-users', authMiddleware.checkAuthentication, Add.prototype.addChatUsers); 21 | this.router.post('/chat/message/remove-chat-users', authMiddleware.checkAuthentication, Add.prototype.removeChatUsers); 22 | this.router.put('/chat/message/mark-as-read', authMiddleware.checkAuthentication, Update.prototype.message); 23 | this.router.put('/chat/message/reaction', authMiddleware.checkAuthentication, Message.prototype.reaction); 24 | this.router.delete( 25 | '/chat/message/mark-as-deleted/:messageId/:senderId/:receiverId/:type', 26 | authMiddleware.checkAuthentication, 27 | Delete.prototype.markMessageAsDeleted 28 | ); 29 | 30 | return this.router; 31 | } 32 | } 33 | 34 | export const chatRoutes: ChatRoutes = new ChatRoutes(); 35 | -------------------------------------------------------------------------------- /src/shared/services/db/block-user.service.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { PushOperator } from 'mongodb'; 3 | import { UserModel } from '@user/models/user.schema'; 4 | 5 | class BlockUserService { 6 | public async blockUser(userId: string, followerId: string): Promise { 7 | UserModel.bulkWrite([ 8 | { 9 | updateOne: { 10 | filter: { _id: userId, blocked: { $ne: new mongoose.Types.ObjectId(followerId) } }, 11 | update: { 12 | $push: { 13 | blocked: new mongoose.Types.ObjectId(followerId) 14 | } as PushOperator 15 | } 16 | } 17 | }, 18 | { 19 | updateOne: { 20 | filter: { _id: followerId, blockedBy: { $ne: new mongoose.Types.ObjectId(userId) } }, 21 | update: { 22 | $push: { 23 | blockedBy: new mongoose.Types.ObjectId(userId) 24 | } as PushOperator 25 | } 26 | } 27 | } 28 | ]); 29 | } 30 | 31 | public async unblockUser(userId: string, followerId: string): Promise { 32 | UserModel.bulkWrite([ 33 | { 34 | updateOne: { 35 | filter: { _id: userId }, 36 | update: { 37 | $pull: { 38 | blocked: new mongoose.Types.ObjectId(followerId) 39 | } as PushOperator 40 | } 41 | } 42 | }, 43 | { 44 | updateOne: { 45 | filter: { _id: followerId }, 46 | update: { 47 | $pull: { 48 | blockedBy: new mongoose.Types.ObjectId(userId) 49 | } as PushOperator 50 | } 51 | } 52 | } 53 | ]); 54 | } 55 | } 56 | 57 | export const blockUserService: BlockUserService = new BlockUserService(); 58 | -------------------------------------------------------------------------------- /src/shared/workers/user.worker.ts: -------------------------------------------------------------------------------- 1 | import { DoneCallback, Job } from 'bull'; 2 | import Logger from 'bunyan'; 3 | import { config } from '@root/config'; 4 | import { userService } from '@service/db/user.service'; 5 | 6 | const log: Logger = config.createLogger('userWorker'); 7 | 8 | class UserWorker { 9 | async addUserToDB(job: Job, done: DoneCallback): Promise { 10 | try { 11 | const { value } = job.data; 12 | await userService.addUserData(value); 13 | job.progress(100); 14 | done(null, job.data); 15 | } catch (error) { 16 | log.error(error); 17 | done(error as Error); 18 | } 19 | } 20 | 21 | async updateUserInfo(job: Job, done: DoneCallback): Promise { 22 | try { 23 | const { key, value } = job.data; 24 | await userService.updateUserInfo(key, value); 25 | job.progress(100); 26 | done(null, job.data); 27 | } catch (error) { 28 | log.error(error); 29 | done(error as Error); 30 | } 31 | } 32 | 33 | async updateSocialLinks(job: Job, done: DoneCallback): Promise { 34 | try { 35 | const { key, value } = job.data; 36 | await userService.updateSocialLinks(key, value); 37 | job.progress(100); 38 | done(null, job.data); 39 | } catch (error) { 40 | log.error(error); 41 | done(error as Error); 42 | } 43 | } 44 | 45 | async updateNotificationSettings(job: Job, done: DoneCallback): Promise { 46 | try { 47 | const { key, value } = job.data; 48 | await userService.updateNotificationSettings(key, value); 49 | job.progress(100); 50 | done(null, job.data); 51 | } catch (error) { 52 | log.error(error); 53 | done(error as Error); 54 | } 55 | } 56 | } 57 | 58 | export const userWorker: UserWorker = new UserWorker(); 59 | -------------------------------------------------------------------------------- /src/features/user/controllers/test/update-settings.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { authUserPayload, authMockRequest, authMockResponse } from '@root/mocks/auth.mock'; 3 | import { UpdateSettings } from '@user/controllers/update-settings'; 4 | import { userQueue } from '@service/queues/user.queue'; 5 | import { UserCache } from '@service/redis/user.cache'; 6 | 7 | jest.useFakeTimers(); 8 | jest.mock('@service/queues/base.queue'); 9 | jest.mock('@service/redis/user.cache'); 10 | 11 | describe('Settings', () => { 12 | beforeEach(() => { 13 | jest.restoreAllMocks(); 14 | }); 15 | 16 | afterAll(async () => { 17 | jest.clearAllMocks(); 18 | jest.clearAllTimers(); 19 | }); 20 | 21 | describe('update', () => { 22 | it('should call "addUserJob" methods', async () => { 23 | const settings = { 24 | messages: true, 25 | reactions: false, 26 | comments: true, 27 | follows: false 28 | }; 29 | const req: Request = authMockRequest({}, settings, authUserPayload) as Request; 30 | const res: Response = authMockResponse(); 31 | jest.spyOn(UserCache.prototype, 'updateSingleUserItemInCache'); 32 | jest.spyOn(userQueue, 'addUserJob'); 33 | 34 | await UpdateSettings.prototype.notification(req, res); 35 | expect(UserCache.prototype.updateSingleUserItemInCache).toHaveBeenCalledWith(`${req.currentUser?.userId}`, 'notifications', req.body); 36 | expect(userQueue.addUserJob).toHaveBeenCalledWith('updateNotificationSettings', { 37 | key: `${req.currentUser?.userId}`, 38 | value: req.body 39 | }); 40 | expect(res.status).toHaveBeenCalledWith(200); 41 | expect(res.json).toHaveBeenCalledWith({ message: 'Notification settings updated successfully', settings: req.body }); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/features/notifications/controllers/test/update-notification.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { Server } from 'socket.io'; 3 | import { authUserPayload } from '@root/mocks/auth.mock'; 4 | import * as notificationServer from '@socket/notification'; 5 | import { notificationMockRequest, notificationMockResponse } from '@root/mocks/notification.mock'; 6 | import { notificationQueue } from '@service/queues/notification.queue'; 7 | import { Update } from '@notification/controllers/update-notification'; 8 | 9 | jest.useFakeTimers(); 10 | jest.mock('@service/queues/base.queue'); 11 | 12 | Object.defineProperties(notificationServer, { 13 | socketIONotificationObject: { 14 | value: new Server(), 15 | writable: true 16 | } 17 | }); 18 | 19 | describe('Update', () => { 20 | beforeEach(() => { 21 | jest.restoreAllMocks(); 22 | }); 23 | 24 | afterEach(() => { 25 | jest.clearAllMocks(); 26 | jest.clearAllTimers(); 27 | }); 28 | 29 | it('should send correct json response', async () => { 30 | const req: Request = notificationMockRequest({}, authUserPayload, { notificationId: '12345' }) as Request; 31 | const res: Response = notificationMockResponse(); 32 | jest.spyOn(notificationServer.socketIONotificationObject, 'emit'); 33 | jest.spyOn(notificationQueue, 'addNotificationJob'); 34 | 35 | await Update.prototype.notification(req, res); 36 | expect(notificationServer.socketIONotificationObject.emit).toHaveBeenCalledWith('update notification', req.params.notificationId); 37 | expect(notificationQueue.addNotificationJob).toHaveBeenCalledWith('updateNotification', { key: req.params.notificationId }); 38 | expect(res.status).toHaveBeenCalledWith(200); 39 | expect(res.json).toHaveBeenCalledWith({ 40 | message: 'Notification marked as read' 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/features/notifications/controllers/test/delete-notification.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { Server } from 'socket.io'; 3 | import { authUserPayload } from '@root/mocks/auth.mock'; 4 | import * as notificationServer from '@socket/notification'; 5 | import { notificationMockRequest, notificationMockResponse } from '@root/mocks/notification.mock'; 6 | import { notificationQueue } from '@service/queues/notification.queue'; 7 | import { Delete } from '@notification/controllers/delete-notification'; 8 | 9 | jest.useFakeTimers(); 10 | jest.mock('@service/queues/base.queue'); 11 | 12 | Object.defineProperties(notificationServer, { 13 | socketIONotificationObject: { 14 | value: new Server(), 15 | writable: true 16 | } 17 | }); 18 | 19 | describe('Delete', () => { 20 | beforeEach(() => { 21 | jest.restoreAllMocks(); 22 | }); 23 | 24 | afterEach(() => { 25 | jest.clearAllMocks(); 26 | jest.clearAllTimers(); 27 | }); 28 | 29 | it('should send correct json response', async () => { 30 | const req: Request = notificationMockRequest({}, authUserPayload, { notificationId: '12345' }) as Request; 31 | const res: Response = notificationMockResponse(); 32 | jest.spyOn(notificationServer.socketIONotificationObject, 'emit'); 33 | jest.spyOn(notificationQueue, 'addNotificationJob'); 34 | 35 | await Delete.prototype.notification(req, res); 36 | expect(notificationServer.socketIONotificationObject.emit).toHaveBeenCalledWith('delete notification', req.params.notificationId); 37 | expect(notificationQueue.addNotificationJob).toHaveBeenCalledWith('deleteNotification', { key: req.params.notificationId }); 38 | expect(res.status).toHaveBeenCalledWith(200); 39 | expect(res.json).toHaveBeenCalledWith({ 40 | message: 'Notification deleted successfully' 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/features/images/controllers/delete-image.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import HTTP_STATUS from 'http-status-codes'; 3 | import { UserCache } from '@service/redis/user.cache'; 4 | import { IUserDocument } from '@user/interfaces/user.interface'; 5 | import { socketIOImageObject } from '@socket/image'; 6 | import { imageQueue } from '@service/queues/image.queue'; 7 | import { IFileImageDocument } from '@image/interfaces/image.interface'; 8 | import { imageService } from '@service/db/image.service'; 9 | 10 | const userCache: UserCache = new UserCache(); 11 | 12 | export class Delete { 13 | public async image(req: Request, res: Response): Promise { 14 | const { imageId } = req.params; 15 | socketIOImageObject.emit('delete image', imageId); 16 | imageQueue.addImageJob('removeImageFromDB', { 17 | imageId 18 | }); 19 | res.status(HTTP_STATUS.OK).json({ message: 'Image deleted successfully' }); 20 | } 21 | 22 | public async backgroundImage(req: Request, res: Response): Promise { 23 | const image: IFileImageDocument = await imageService.getImageByBackgroundId(req.params.bgImageId); 24 | socketIOImageObject.emit('delete image', image?._id); 25 | const bgImageId: Promise = userCache.updateSingleUserItemInCache( 26 | `${req.currentUser!.userId}`, 27 | 'bgImageId', 28 | '' 29 | ) as Promise; 30 | const bgImageVersion: Promise = userCache.updateSingleUserItemInCache( 31 | `${req.currentUser!.userId}`, 32 | 'bgImageVersion', 33 | '' 34 | ) as Promise; 35 | (await Promise.all([bgImageId, bgImageVersion])) as [IUserDocument, IUserDocument]; 36 | imageQueue.addImageJob('removeImageFromDB', { 37 | imageId: image?._id 38 | }); 39 | res.status(HTTP_STATUS.OK).json({ message: 'Image deleted successfully' }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/features/user/routes/userRoutes.ts: -------------------------------------------------------------------------------- 1 | import express, { Router } from 'express'; 2 | import { authMiddleware } from '@global/helpers/auth-middleware'; 3 | import { Get } from '@user/controllers/get-profile'; 4 | import { Search } from '@user/controllers/search-user'; 5 | import { Update } from '@user/controllers/change-password'; 6 | import { Edit } from '@user/controllers/update-basic-info'; 7 | import { UpdateSettings } from '@user/controllers/update-settings'; 8 | 9 | class UserRoutes { 10 | private router: Router; 11 | 12 | constructor() { 13 | this.router = express.Router(); 14 | } 15 | 16 | public routes(): Router { 17 | this.router.get('/user/all/:page', authMiddleware.checkAuthentication, Get.prototype.all); 18 | this.router.get('/user/profile', authMiddleware.checkAuthentication, Get.prototype.profile); 19 | this.router.get('/user/profile/:userId', authMiddleware.checkAuthentication, Get.prototype.profileByUserId); 20 | this.router.get('/user/profile/posts/:username/:userId/:uId', authMiddleware.checkAuthentication, Get.prototype.profileAndPosts); 21 | this.router.get('/user/profile/user/suggestions', authMiddleware.checkAuthentication, Get.prototype.randomUserSuggestions); 22 | this.router.get('/user/profile/search/:query', authMiddleware.checkAuthentication, Search.prototype.user); 23 | 24 | this.router.put('/user/profile/change-password', authMiddleware.checkAuthentication, Update.prototype.password); 25 | this.router.put('/user/profile/basic-info', authMiddleware.checkAuthentication, Edit.prototype.info); 26 | this.router.put('/user/profile/social-links', authMiddleware.checkAuthentication, Edit.prototype.social); 27 | this.router.put('/user/profile/settings', authMiddleware.checkAuthentication, UpdateSettings.prototype.notification); 28 | 29 | return this.router; 30 | } 31 | } 32 | 33 | export const userRoutes: UserRoutes = new UserRoutes(); 34 | -------------------------------------------------------------------------------- /src/shared/services/db/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { IAuthDocument } from '@auth/interfaces/auth.interface'; 2 | import { AuthModel } from '@auth/models/auth.schema'; 3 | import { Helpers } from '@global/helpers/helpers'; 4 | 5 | class AuthService { 6 | public async createAuthUser(data: IAuthDocument): Promise { 7 | await AuthModel.create(data); 8 | } 9 | 10 | public async updatePasswordToken(authId: string, token: string, tokenExpiration: number): Promise { 11 | await AuthModel.updateOne( 12 | { _id: authId }, 13 | { 14 | passwordResetToken: token, 15 | passwordResetExpires: tokenExpiration 16 | } 17 | ); 18 | } 19 | 20 | public async getUserByUsernameOrEmail(username: string, email: string): Promise { 21 | const query = { 22 | $or: [{ username: Helpers.firstLetterUppercase(username) }, { email: Helpers.lowerCase(email) }] 23 | }; 24 | const user: IAuthDocument = (await AuthModel.findOne(query).exec()) as IAuthDocument; 25 | return user; 26 | } 27 | 28 | public async getAuthUserByUsername(username: string): Promise { 29 | const user: IAuthDocument = (await AuthModel.findOne({ username: Helpers.firstLetterUppercase(username) }).exec()) as IAuthDocument; 30 | return user; 31 | } 32 | 33 | public async getAuthUserByEmail(email: string): Promise { 34 | const user: IAuthDocument = (await AuthModel.findOne({ email: Helpers.lowerCase(email) }).exec()) as IAuthDocument; 35 | return user; 36 | } 37 | 38 | public async getAuthUserByPasswordToken(token: string): Promise { 39 | const user: IAuthDocument = (await AuthModel.findOne({ 40 | passwordResetToken: token, 41 | passwordResetExpires: { $gt: Date.now() } 42 | }).exec()) as IAuthDocument; 43 | return user; 44 | } 45 | } 46 | 47 | export const authService: AuthService = new AuthService(); 48 | -------------------------------------------------------------------------------- /src/shared/workers/image.worker.ts: -------------------------------------------------------------------------------- 1 | import { DoneCallback, Job } from 'bull'; 2 | import Logger from 'bunyan'; 3 | import { config } from '@root/config'; 4 | import { imageService } from '@service/db/image.service'; 5 | 6 | const log: Logger = config.createLogger('imageWorker'); 7 | 8 | class ImageWorker { 9 | async addUserProfileImageToDB(job: Job, done: DoneCallback): Promise { 10 | try { 11 | const { key, value, imgId, imgVersion } = job.data; 12 | await imageService.addUserProfileImageToDB(key, value, imgId, imgVersion); 13 | job.progress(100); 14 | done(null, job.data); 15 | } catch (error) { 16 | log.error(error); 17 | done(error as Error); 18 | } 19 | } 20 | 21 | async updateBGImageInDB(job: Job, done: DoneCallback): Promise { 22 | try { 23 | const { key, imgId, imgVersion } = job.data; 24 | await imageService.addBackgroundImageToDB(key, imgId, imgVersion); 25 | job.progress(100); 26 | done(null, job.data); 27 | } catch (error) { 28 | log.error(error); 29 | done(error as Error); 30 | } 31 | } 32 | 33 | async addImageToDB(job: Job, done: DoneCallback): Promise { 34 | try { 35 | const { key, imgId, imgVersion } = job.data; 36 | await imageService.addImage(key, imgId, imgVersion, ''); 37 | job.progress(100); 38 | done(null, job.data); 39 | } catch (error) { 40 | log.error(error); 41 | done(error as Error); 42 | } 43 | } 44 | 45 | async removeImageFromDB(job: Job, done: DoneCallback): Promise { 46 | try { 47 | const { imageId } = job.data; 48 | await imageService.removeImageFromDB(imageId); 49 | job.progress(100); 50 | done(null, job.data); 51 | } catch (error) { 52 | log.error(error); 53 | done(error as Error); 54 | } 55 | } 56 | } 57 | 58 | export const imageWorker: ImageWorker = new ImageWorker(); 59 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import express, { Express } from 'express'; 2 | import { ChattyServer } from '@root/setupServer'; 3 | import databaseConnection from '@root/setupDatabase'; 4 | import { config } from '@root/config'; 5 | import Logger from 'bunyan'; 6 | 7 | const log: Logger = config.createLogger('app'); 8 | 9 | class Application { 10 | public initialize(): void { 11 | this.loadConfig(); 12 | databaseConnection(); 13 | const app: Express = express(); 14 | const server: ChattyServer = new ChattyServer(app); 15 | server.start(); 16 | Application.handleExit(); 17 | } 18 | 19 | private loadConfig(): void { 20 | config.validateConfig(); 21 | config.cloudinaryConfig(); 22 | } 23 | 24 | private static handleExit(): void { 25 | process.on('uncaughtException', (error: Error) => { 26 | log.error(`There was an uncaught error: ${error}`); 27 | Application.shutDownProperly(1); 28 | }); 29 | 30 | process.on('unhandleRejection', (reason: Error) => { 31 | log.error(`Unhandled rejection at promise: ${reason}`); 32 | Application.shutDownProperly(2); 33 | }); 34 | 35 | process.on('SIGTERM', () => { 36 | log.error('Caught SIGTERM'); 37 | Application.shutDownProperly(2); 38 | }); 39 | 40 | process.on('SIGINT', () => { 41 | log.error('Caught SIGINT'); 42 | Application.shutDownProperly(2); 43 | }); 44 | 45 | process.on('exit', () => { 46 | log.error('Exiting'); 47 | }); 48 | } 49 | 50 | private static shutDownProperly(exitCode: number): void { 51 | Promise.resolve() 52 | .then(() => { 53 | log.info('Shutdown complete'); 54 | process.exit(exitCode); 55 | }) 56 | .catch((error) => { 57 | log.error(`Error during shutdown: ${error}`); 58 | process.exit(1); 59 | }); 60 | } 61 | } 62 | 63 | const application: Application = new Application(); 64 | application.initialize(); 65 | -------------------------------------------------------------------------------- /src/features/user/schemes/info.ts: -------------------------------------------------------------------------------- 1 | import Joi, { ObjectSchema } from 'joi'; 2 | 3 | const basicInfoSchema: ObjectSchema = Joi.object().keys({ 4 | quote: Joi.string().optional().allow(null, ''), 5 | work: Joi.string().optional().allow(null, ''), 6 | school: Joi.string().optional().allow(null, ''), 7 | location: Joi.string().optional().allow(null, '') 8 | }); 9 | 10 | const socialLinksSchema: ObjectSchema = Joi.object().keys({ 11 | facebook: Joi.string().optional().allow(null, ''), 12 | instagram: Joi.string().optional().allow(null, ''), 13 | twitter: Joi.string().optional().allow(null, ''), 14 | youtube: Joi.string().optional().allow(null, '') 15 | }); 16 | 17 | const changePasswordSchema: ObjectSchema = Joi.object().keys({ 18 | currentPassword: Joi.string().required().min(4).max(8).messages({ 19 | 'string.base': 'Password should be a type of string', 20 | 'string.min': 'Password must have a minimum length of {#limit}', 21 | 'string.max': 'Password should have a maximum length of {#limit}', 22 | 'string.empty': 'Password is a required field' 23 | }), 24 | newPassword: Joi.string().required().min(4).max(8).messages({ 25 | 'string.base': 'Password should be a type of string', 26 | 'string.min': 'Password must have a minimum length of {#limit}', 27 | 'string.max': 'Password should have a maximum length of {#limit}', 28 | 'string.empty': 'Password is a required field' 29 | }), 30 | confirmPassword: Joi.any().equal(Joi.ref('newPassword')).required().messages({ 31 | 'any.only': 'Confirm password does not match new password.' 32 | }) 33 | }); 34 | 35 | const notificationSettingsSchema: ObjectSchema = Joi.object().keys({ 36 | messages: Joi.boolean().optional(), 37 | reactions: Joi.boolean().optional(), 38 | comments: Joi.boolean().optional(), 39 | follows: Joi.boolean().optional() 40 | }); 41 | 42 | export { basicInfoSchema, socialLinksSchema, changePasswordSchema, notificationSettingsSchema }; 43 | -------------------------------------------------------------------------------- /deployment/15-alb.tf: -------------------------------------------------------------------------------- 1 | resource "aws_alb" "application_load_balancer" { 2 | name = "${local.prefix}-alb" 3 | load_balancer_type = "application" 4 | internal = false 5 | subnets = [aws_subnet.public_subnet_a.id, aws_subnet.public_subnet_b.id] 6 | security_groups = [aws_security_group.alb_sg.id] 7 | enable_deletion_protection = false 8 | ip_address_type = "ipv4" 9 | idle_timeout = 300 10 | 11 | tags = merge( 12 | local.common_tags, 13 | tomap({ "Name" = "${local.prefix}-ALB" }) 14 | ) 15 | } 16 | 17 | resource "aws_alb_listener" "alb_https_listener" { 18 | load_balancer_arn = aws_alb.application_load_balancer.arn 19 | port = 443 20 | protocol = "HTTPS" 21 | ssl_policy = var.https_ssl_policy 22 | certificate_arn = aws_acm_certificate_validation.cert_validation.certificate_arn 23 | 24 | depends_on = [ 25 | aws_acm_certificate_validation.cert_validation 26 | ] 27 | 28 | default_action { 29 | type = "forward" 30 | target_group_arn = aws_alb_target_group.server_backend_tg.arn 31 | } 32 | } 33 | 34 | resource "aws_alb_listener" "alb_http_listener" { 35 | load_balancer_arn = aws_alb.application_load_balancer.arn 36 | port = 80 37 | protocol = "HTTP" 38 | 39 | default_action { 40 | type = "redirect" 41 | redirect { 42 | port = "443" 43 | protocol = "HTTPS" 44 | status_code = "HTTP_301" 45 | } 46 | } 47 | } 48 | 49 | resource "aws_alb_listener_rule" "alb_https_listener_rule" { 50 | listener_arn = aws_alb_listener.alb_http_listener.arn 51 | priority = 100 52 | action { 53 | type = "forward" 54 | target_group_arn = aws_alb_target_group.server_backend_tg.arn 55 | } 56 | 57 | condition { 58 | path_pattern { 59 | values = ["/*"] 60 | } 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/shared/services/db/image.service.ts: -------------------------------------------------------------------------------- 1 | import { IFileImageDocument } from '@image/interfaces/image.interface'; 2 | import { ImageModel } from '@image/models/image.schema'; 3 | import { UserModel } from '@user/models/user.schema'; 4 | import mongoose from 'mongoose'; 5 | 6 | class ImageService { 7 | public async addUserProfileImageToDB(userId: string, url: string, imgId: string, imgVersion: string): Promise { 8 | await UserModel.updateOne({ _id: userId }, { $set: { profilePicture: url } }).exec(); 9 | await this.addImage(userId, imgId, imgVersion, 'profile'); 10 | } 11 | 12 | public async addBackgroundImageToDB(userId: string, imgId: string, imgVersion: string): Promise { 13 | await UserModel.updateOne({ _id: userId }, { $set: { bgImageId: imgId, bgImageVersion: imgVersion } }).exec(); 14 | await this.addImage(userId, imgId, imgVersion, 'background'); 15 | } 16 | 17 | public async addImage(userId: string, imgId: string, imgVersion: string, type: string): Promise { 18 | await ImageModel.create({ 19 | userId, 20 | bgImageVersion: type === 'background' ? imgVersion : '', 21 | bgImageId: type === 'background' ? imgId : '', 22 | imgVersion, 23 | imgId 24 | }); 25 | } 26 | 27 | public async removeImageFromDB(imageId: string): Promise { 28 | await ImageModel.deleteOne({ _id: imageId }).exec(); 29 | } 30 | 31 | public async getImageByBackgroundId(bgImageId: string): Promise { 32 | const image: IFileImageDocument = (await ImageModel.findOne({ bgImageId }).exec()) as IFileImageDocument; 33 | return image; 34 | } 35 | 36 | public async getImages(userId: string): Promise { 37 | const images: IFileImageDocument[] = await ImageModel.aggregate([{ $match: { userId: new mongoose.Types.ObjectId(userId) } }]); 38 | return images; 39 | } 40 | } 41 | 42 | export const imageService: ImageService = new ImageService(); 43 | -------------------------------------------------------------------------------- /src/features/user/controllers/test/search-user.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { chatMockRequest, chatMockResponse } from '@root/mocks/chat.mock'; 3 | import { authUserPayload } from '@root/mocks/auth.mock'; 4 | import { searchedUserMock } from '@root/mocks/user.mock'; 5 | import { Search } from '@user/controllers/search-user'; 6 | import { userService } from '@service/db/user.service'; 7 | 8 | jest.useFakeTimers(); 9 | jest.mock('@service/queues/base.queue'); 10 | jest.mock('@service/redis/user.cache'); 11 | 12 | describe('Search', () => { 13 | beforeEach(() => { 14 | jest.restoreAllMocks(); 15 | }); 16 | 17 | afterEach(() => { 18 | jest.clearAllMocks(); 19 | jest.clearAllTimers(); 20 | }); 21 | 22 | describe('user', () => { 23 | it('should send correct json response if searched user exist', async () => { 24 | const req: Request = chatMockRequest({}, {}, authUserPayload, { query: 'Danny' }) as Request; 25 | const res: Response = chatMockResponse(); 26 | jest.spyOn(userService, 'searchUsers').mockResolvedValue([searchedUserMock]); 27 | 28 | await Search.prototype.user(req, res); 29 | expect(res.status).toHaveBeenCalledWith(200); 30 | expect(res.json).toHaveBeenCalledWith({ 31 | message: 'Search results', 32 | search: [searchedUserMock] 33 | }); 34 | }); 35 | 36 | it('should send correct json response if searched user does not exist', async () => { 37 | const req: Request = chatMockRequest({}, {}, authUserPayload, { query: 'DannyBoy' }) as Request; 38 | const res: Response = chatMockResponse(); 39 | jest.spyOn(userService, 'searchUsers').mockResolvedValue([]); 40 | 41 | await Search.prototype.user(req, res); 42 | expect(res.status).toHaveBeenCalledWith(200); 43 | expect(res.json).toHaveBeenCalledWith({ 44 | message: 'Search results', 45 | search: [] 46 | }); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/shared/workers/chat.worker.ts: -------------------------------------------------------------------------------- 1 | import { config } from '@root/config'; 2 | import { chatService } from '@service/db/chat.service'; 3 | import { DoneCallback, Job } from 'bull'; 4 | import Logger from 'bunyan'; 5 | 6 | const log: Logger = config.createLogger('chatWorker'); 7 | 8 | class ChatWorker { 9 | async addChatMessageToDB(jobQueue: Job, done: DoneCallback): Promise { 10 | try { 11 | await chatService.addMessageToDB(jobQueue.data); 12 | jobQueue.progress(100); 13 | done(null, jobQueue.data); 14 | } catch (error) { 15 | log.error(error); 16 | done(error as Error); 17 | } 18 | } 19 | 20 | async markMessageAsDeleted(jobQueue: Job, done: DoneCallback): Promise { 21 | try { 22 | const { messageId, type } = jobQueue.data; 23 | await chatService.markMessageAsDeleted(messageId, type); 24 | jobQueue.progress(100); 25 | done(null, jobQueue.data); 26 | } catch (error) { 27 | log.error(error); 28 | done(error as Error); 29 | } 30 | } 31 | 32 | async markMessagesAsReadInDB(jobQueue: Job, done: DoneCallback): Promise { 33 | try { 34 | const { senderId, receiverId } = jobQueue.data; 35 | await chatService.markMessagesAsRead(senderId, receiverId); 36 | jobQueue.progress(100); 37 | done(null, jobQueue.data); 38 | } catch (error) { 39 | log.error(error); 40 | done(error as Error); 41 | } 42 | } 43 | 44 | async updateMessageReaction(jobQueue: Job, done: DoneCallback): Promise { 45 | try { 46 | const { messageId, senderName, reaction, type } = jobQueue.data; 47 | await chatService.updateMessageReaction(messageId, senderName, reaction, type); 48 | jobQueue.progress(100); 49 | done(null, jobQueue.data); 50 | } catch (error) { 51 | log.error(error); 52 | done(error as Error); 53 | } 54 | } 55 | } 56 | 57 | export const chatWorker: ChatWorker = new ChatWorker(); 58 | -------------------------------------------------------------------------------- /src/features/post/controllers/test/delete-post.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { Server } from 'socket.io'; 3 | import { authUserPayload } from '@root/mocks/auth.mock'; 4 | import * as postServer from '@socket/post'; 5 | import { newPost, postMockRequest, postMockResponse } from '@root/mocks/post.mock'; 6 | import { postQueue } from '@service/queues/post.queue'; 7 | import { Delete } from '@post/controllers/delete-post'; 8 | import { PostCache } from '@service/redis/post.cache'; 9 | 10 | jest.useFakeTimers(); 11 | jest.mock('@service/queues/base.queue'); 12 | jest.mock('@service/redis/post.cache'); 13 | 14 | Object.defineProperties(postServer, { 15 | socketIOPostObject: { 16 | value: new Server(), 17 | writable: true 18 | } 19 | }); 20 | 21 | describe('Delete', () => { 22 | beforeEach(() => { 23 | jest.restoreAllMocks(); 24 | }); 25 | 26 | afterEach(() => { 27 | jest.clearAllMocks(); 28 | jest.clearAllTimers(); 29 | }); 30 | 31 | it('should send correct json response', async () => { 32 | const req: Request = postMockRequest(newPost, authUserPayload, { postId: '12345' }) as Request; 33 | const res: Response = postMockResponse(); 34 | jest.spyOn(postServer.socketIOPostObject, 'emit'); 35 | jest.spyOn(PostCache.prototype, 'deletePostFromCache'); 36 | jest.spyOn(postQueue, 'addPostJob'); 37 | 38 | await Delete.prototype.post(req, res); 39 | expect(postServer.socketIOPostObject.emit).toHaveBeenCalledWith('delete post', req.params.postId); 40 | expect(PostCache.prototype.deletePostFromCache).toHaveBeenCalledWith(req.params.postId, `${req.currentUser?.userId}`); 41 | expect(postQueue.addPostJob).toHaveBeenCalledWith('deletePostFromDB', { keyOne: req.params.postId, keyTwo: req.currentUser?.userId }); 42 | expect(res.status).toHaveBeenCalledWith(200); 43 | expect(res.json).toHaveBeenCalledWith({ 44 | message: 'Post deleted successfully' 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/mocks/auth.mock.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/no-empty-function */ 3 | import { Response } from 'express'; 4 | import { AuthPayload, IAuthDocument } from '@auth/interfaces/auth.interface'; 5 | 6 | export const authMockRequest = (sessionData: IJWT, body: IAuthMock, currentUser?: AuthPayload | null, params?: any) => ({ 7 | session: sessionData, 8 | body, 9 | params, 10 | currentUser 11 | }); 12 | 13 | export const authMockResponse = (): Response => { 14 | const res: Response = {} as Response; 15 | res.status = jest.fn().mockReturnValue(res); 16 | res.json = jest.fn().mockReturnValue(res); 17 | return res; 18 | }; 19 | 20 | export interface IJWT { 21 | jwt?: string; 22 | } 23 | 24 | export interface IAuthMock { 25 | _id?: string; 26 | username?: string; 27 | email?: string; 28 | uId?: string; 29 | password?: string; 30 | avatarColor?: string; 31 | avatarImage?: string; 32 | createdAt?: Date | string; 33 | currentPassword?: string; 34 | newPassword?: string; 35 | confirmPassword?: string; 36 | quote?: string; 37 | work?: string; 38 | school?: string; 39 | location?: string; 40 | facebook?: string; 41 | instagram?: string; 42 | twitter?: string; 43 | youtube?: string; 44 | messages?: boolean; 45 | reactions?: boolean; 46 | comments?: boolean; 47 | follows?: boolean; 48 | } 49 | 50 | export const authUserPayload: AuthPayload = { 51 | userId: '60263f14648fed5246e322d9', 52 | uId: '1621613119252066', 53 | username: 'Manny', 54 | email: 'manny@me.com', 55 | avatarColor: '#9c27b0', 56 | iat: 12345 57 | }; 58 | 59 | export const authMock = { 60 | _id: '60263f14648fed5246e322d3', 61 | uId: '1621613119252066', 62 | username: 'Manny', 63 | email: 'manny@me.com', 64 | avatarColor: '#9c27b0', 65 | createdAt: '2022-08-31T07:42:24.451Z', 66 | save: () => {}, 67 | comparePassword: () => false 68 | } as unknown as IAuthDocument; 69 | -------------------------------------------------------------------------------- /src/mocks/followers.mock.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express'; 2 | import { IJWT } from './auth.mock'; 3 | import { AuthPayload } from '@auth/interfaces/auth.interface'; 4 | import { existingUserTwo } from '@root/mocks/user.mock'; 5 | import mongoose from 'mongoose'; 6 | import { IFollowerData } from '@follower/interfaces/follower.interface'; 7 | 8 | export const followersMockRequest = (sessionData: IJWT, currentUser?: AuthPayload | null, params?: IParams) => ({ 9 | session: sessionData, 10 | params, 11 | currentUser 12 | }); 13 | 14 | export const followersMockResponse = (): Response => { 15 | const res: Response = {} as Response; 16 | res.status = jest.fn().mockReturnValue(res); 17 | res.json = jest.fn().mockReturnValue(res); 18 | return res; 19 | }; 20 | 21 | export interface IParams { 22 | followerId?: string; 23 | followeeId?: string; 24 | userId?: string; 25 | } 26 | 27 | export const mockFollowerData: IFollowerData = { 28 | avatarColor: `${existingUserTwo.avatarColor}`, 29 | followersCount: existingUserTwo.followersCount, 30 | followingCount: existingUserTwo.followingCount, 31 | profilePicture: `${existingUserTwo.profilePicture}`, 32 | postCount: existingUserTwo.postsCount, 33 | username: `${existingUserTwo.username}`, 34 | uId: `${existingUserTwo.uId}`, 35 | _id: new mongoose.Types.ObjectId(existingUserTwo._id) 36 | }; 37 | 38 | export const followerData = { 39 | _id: '605727cd646cb50e668a4e13', 40 | followerId: { 41 | username: 'Manny', 42 | postCount: 5, 43 | avatarColor: '#ff9800', 44 | followersCount: 3, 45 | followingCount: 5, 46 | profilePicture: 'https://res.cloudinary.com/ratingapp/image/upload/605727cd646eb50e668a4e13' 47 | }, 48 | followeeId: { 49 | username: 'Danny', 50 | postCount: 10, 51 | avatarColor: '#ff9800', 52 | followersCount: 3, 53 | followingCount: 5, 54 | profilePicture: 'https://res.cloudinary.com/ratingapp/image/upload/605727cd646eb50e668a4e13' 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /src/features/reactions/controllers/test/remove-reaction.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { reactionMockRequest, reactionMockResponse } from '@root/mocks/reactions.mock'; 3 | import { authUserPayload } from '@root/mocks/auth.mock'; 4 | import { ReactionCache } from '@service/redis/reaction.cache'; 5 | import { reactionQueue } from '@service/queues/reaction.queue'; 6 | import { Remove } from '@reaction/controllers/remove-reaction'; 7 | 8 | jest.useFakeTimers(); 9 | jest.mock('@service/queues/base.queue'); 10 | jest.mock('@service/redis/reaction.cache'); 11 | 12 | describe('Remove', () => { 13 | beforeEach(() => { 14 | jest.restoreAllMocks(); 15 | }); 16 | 17 | afterEach(() => { 18 | jest.clearAllMocks(); 19 | jest.clearAllTimers(); 20 | }); 21 | 22 | it('should send correct json response', async () => { 23 | const req: Request = reactionMockRequest({}, {}, authUserPayload, { 24 | postId: '6027f77087c9d9ccb1555268', 25 | previousReaction: 'like', 26 | postReactions: JSON.stringify({ 27 | like: 1, 28 | love: 0, 29 | happy: 0, 30 | wow: 0, 31 | sad: 0, 32 | angry: 0 33 | }) 34 | }) as Request; 35 | const res: Response = reactionMockResponse(); 36 | jest.spyOn(ReactionCache.prototype, 'removePostReactionFromCache'); 37 | const spy = jest.spyOn(reactionQueue, 'addReactionJob'); 38 | 39 | await Remove.prototype.reaction(req, res); 40 | expect(ReactionCache.prototype.removePostReactionFromCache).toHaveBeenCalledWith( 41 | '6027f77087c9d9ccb1555268', 42 | `${req.currentUser?.username}`, 43 | JSON.parse(req.params.postReactions) 44 | ); 45 | expect(reactionQueue.addReactionJob).toHaveBeenCalledWith(spy.mock.calls[0][0], spy.mock.calls[0][1]); 46 | expect(res.status).toHaveBeenCalledWith(200); 47 | expect(res.json).toHaveBeenCalledWith({ 48 | message: 'Reaction removed from post' 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/shared/services/db/notification.service.ts: -------------------------------------------------------------------------------- 1 | import { INotificationDocument } from '@notification/interfaces/notification.interface'; 2 | import { NotificationModel } from '@notification/models/notification.schema'; 3 | import mongoose from 'mongoose'; 4 | 5 | class NotificationService { 6 | public async getNotifications(userId: string): Promise { 7 | const notifications: INotificationDocument[] = await NotificationModel.aggregate([ 8 | { $match: { userTo: new mongoose.Types.ObjectId(userId) } }, 9 | { $lookup: { from: 'User', localField: 'userFrom', foreignField: '_id', as: 'userFrom' } }, 10 | { $unwind: '$userFrom' }, 11 | { $lookup: { from: 'Auth', localField: 'userFrom.authId', foreignField: '_id', as: 'authId' } }, 12 | { $unwind: '$authId' }, 13 | { 14 | $project: { 15 | _id: 1, 16 | message: 1, 17 | comment: 1, 18 | createdAt: 1, 19 | createdItemId: 1, 20 | entityId: 1, 21 | notificationType: 1, 22 | gifUrl: 1, 23 | imgId: 1, 24 | imgVersion: 1, 25 | post: 1, 26 | reaction: 1, 27 | read: 1, 28 | userTo: 1, 29 | userFrom: { 30 | profilePicture: '$userFrom.profilePicture', 31 | username: '$authId.username', 32 | avatarColor: '$authId.avatarColor', 33 | uId: '$authId.uId' 34 | } 35 | } 36 | } 37 | ]); 38 | return notifications; 39 | } 40 | 41 | public async updateNotification(notificationId: string): Promise { 42 | await NotificationModel.updateOne({ _id: notificationId }, { $set: { read: true } }).exec(); 43 | } 44 | 45 | public async deleteNotification(notificationId: string): Promise { 46 | await NotificationModel.deleteOne({ _id: notificationId }).exec(); 47 | } 48 | } 49 | 50 | export const notificationService: NotificationService = new NotificationService(); 51 | -------------------------------------------------------------------------------- /src/shared/globals/helpers/error-handler.ts: -------------------------------------------------------------------------------- 1 | import HTTP_STATUS from 'http-status-codes'; 2 | 3 | export interface IErrorResponse { 4 | message: string; 5 | statusCode: number; 6 | status: string; 7 | serializeErrors(): IError; 8 | } 9 | 10 | export interface IError { 11 | message: string; 12 | statusCode: number; 13 | status: string; 14 | } 15 | 16 | export abstract class CustomError extends Error { 17 | abstract statusCode: number; 18 | abstract status: string; 19 | 20 | constructor(message: string) { 21 | super(message); 22 | } 23 | 24 | serializeErrors(): IError { 25 | return { 26 | message: this.message, 27 | status: this.status, 28 | statusCode: this.statusCode 29 | }; 30 | } 31 | } 32 | 33 | export class JoiRequestValidationError extends CustomError { 34 | statusCode = HTTP_STATUS.BAD_REQUEST; 35 | status = 'error'; 36 | 37 | constructor(message: string) { 38 | super(message); 39 | } 40 | } 41 | 42 | export class BadRequestError extends CustomError { 43 | statusCode = HTTP_STATUS.BAD_REQUEST; 44 | status = 'error'; 45 | 46 | constructor(message: string) { 47 | super(message); 48 | } 49 | } 50 | 51 | export class NotFoundError extends CustomError { 52 | statusCode = HTTP_STATUS.NOT_FOUND; 53 | status = 'error'; 54 | 55 | constructor(message: string) { 56 | super(message); 57 | } 58 | } 59 | 60 | export class NotAuthorizedError extends CustomError { 61 | statusCode = HTTP_STATUS.UNAUTHORIZED; 62 | status = 'error'; 63 | 64 | constructor(message: string) { 65 | super(message); 66 | } 67 | } 68 | 69 | export class FileTooLargeError extends CustomError { 70 | statusCode = HTTP_STATUS.REQUEST_TOO_LONG; 71 | status = 'error'; 72 | 73 | constructor(message: string) { 74 | super(message); 75 | } 76 | } 77 | 78 | export class ServerError extends CustomError { 79 | statusCode = HTTP_STATUS.SERVICE_UNAVAILABLE; 80 | status = 'error'; 81 | 82 | constructor(message: string) { 83 | super(message); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | import { Application } from 'express'; 2 | import { authRoutes } from '@auth/routes/authRoutes'; 3 | import { serverAdapter } from '@service/queues/base.queue'; 4 | import { currentUserRoutes } from '@auth/routes/currentRoutes'; 5 | import { authMiddleware } from '@global/helpers/auth-middleware'; 6 | import { postRoutes } from '@post/routes/postRoutes'; 7 | import { reactionRoutes } from '@reaction/routes/reactionRoutes'; 8 | import { commentRoutes } from '@comment/routes/commentRoutes'; 9 | import { followerRoutes } from '@follower/routes/followerRoutes'; 10 | import { notificationRoutes } from '@notification/routes/notificationRoutes'; 11 | import { imageRoutes } from '@image/routes/imageRoutes'; 12 | import { chatRoutes } from '@chat/routes/chatRoutes'; 13 | import { userRoutes } from '@user/routes/userRoutes'; 14 | import { healthRoutes } from '@user/routes/healthRoutes'; 15 | 16 | const BASE_PATH = '/api/v1'; 17 | 18 | export default (app: Application) => { 19 | const routes = () => { 20 | app.use('/queues', serverAdapter.getRouter()); 21 | app.use('', healthRoutes.health()); 22 | app.use('', healthRoutes.env()); 23 | app.use('', healthRoutes.instance()); 24 | app.use('', healthRoutes.fiboRoutes()); 25 | 26 | app.use(BASE_PATH, authRoutes.routes()); 27 | app.use(BASE_PATH, authRoutes.signoutRoute()); 28 | 29 | app.use(BASE_PATH, authMiddleware.verifyUser, currentUserRoutes.routes()); 30 | app.use(BASE_PATH, authMiddleware.verifyUser, postRoutes.routes()); 31 | app.use(BASE_PATH, authMiddleware.verifyUser, reactionRoutes.routes()); 32 | app.use(BASE_PATH, authMiddleware.verifyUser, commentRoutes.routes()); 33 | app.use(BASE_PATH, authMiddleware.verifyUser, followerRoutes.routes()); 34 | app.use(BASE_PATH, authMiddleware.verifyUser, notificationRoutes.routes()); 35 | app.use(BASE_PATH, authMiddleware.verifyUser, imageRoutes.routes()); 36 | app.use(BASE_PATH, authMiddleware.verifyUser, chatRoutes.routes()); 37 | app.use(BASE_PATH, authMiddleware.verifyUser, userRoutes.routes()); 38 | }; 39 | routes(); 40 | }; 41 | -------------------------------------------------------------------------------- /src/features/notifications/models/notification.schema.ts: -------------------------------------------------------------------------------- 1 | import { INotificationDocument, INotification } from '@notification/interfaces/notification.interface'; 2 | import { notificationService } from '@service/db/notification.service'; 3 | import mongoose, { model, Model, Schema } from 'mongoose'; 4 | 5 | const notificationSchema: Schema = new Schema({ 6 | userTo: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true }, 7 | userFrom: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, 8 | read: { type: Boolean, default: false }, 9 | message: { type: String, default: '' }, 10 | notificationType: String, 11 | entityId: mongoose.Types.ObjectId, 12 | createdItemId: mongoose.Types.ObjectId, 13 | comment: { type: String, default: '' }, 14 | reaction: { type: String, default: '' }, 15 | post: { type: String, default: '' }, 16 | imgId: { type: String, default: '' }, 17 | imgVersion: { type: String, default: '' }, 18 | gifUrl: { type: String, default: '' }, 19 | createdAt: { type: Date, default: Date.now() } 20 | }); 21 | 22 | notificationSchema.methods.insertNotification = async function (body: INotification) { 23 | const { 24 | userTo, 25 | userFrom, 26 | message, 27 | notificationType, 28 | entityId, 29 | createdItemId, 30 | createdAt, 31 | comment, 32 | reaction, 33 | post, 34 | imgId, 35 | imgVersion, 36 | gifUrl 37 | } = body; 38 | 39 | await NotificationModel.create({ 40 | userTo, 41 | userFrom, 42 | message, 43 | notificationType, 44 | entityId, 45 | createdItemId, 46 | createdAt, 47 | comment, 48 | reaction, 49 | post, 50 | imgId, 51 | imgVersion, 52 | gifUrl 53 | }); 54 | try { 55 | const notifications: INotificationDocument[] = await notificationService.getNotifications(userTo); 56 | return notifications; 57 | } catch (error) { 58 | return error; 59 | } 60 | }; 61 | 62 | const NotificationModel: Model = model('Notification', notificationSchema, 'Notification'); 63 | export { NotificationModel }; 64 | -------------------------------------------------------------------------------- /src/shared/sockets/user.ts: -------------------------------------------------------------------------------- 1 | import { ILogin, ISocketData } from '@user/interfaces/user.interface'; 2 | import { Server, Socket } from 'socket.io'; 3 | 4 | export let socketIOUserObject: Server; 5 | export const connectedUsersMap: Map = new Map(); 6 | let users: string[] = []; 7 | 8 | export class SocketIOUserHandler { 9 | private io: Server; 10 | 11 | constructor(io: Server) { 12 | this.io = io; 13 | socketIOUserObject = io; 14 | } 15 | 16 | public listen(): void { 17 | this.io.on('connection', (socket: Socket) => { 18 | socket.on('setup', (data: ILogin) => { 19 | this.addClientToMap(data.userId, socket.id); 20 | this.addUser(data.userId); 21 | this.io.emit('user online', users); 22 | }); 23 | 24 | socket.on('block user', (data: ISocketData) => { 25 | this.io.emit('blocked user id', data); 26 | }); 27 | 28 | socket.on('unblock user', (data: ISocketData) => { 29 | this.io.emit('unblocked user id', data); 30 | }); 31 | 32 | socket.on('disconnect', () => { 33 | this.removeClientFromMap(socket.id); 34 | }); 35 | }); 36 | } 37 | 38 | private addClientToMap(username: string, socketId: string): void { 39 | if (!connectedUsersMap.has(username)) { 40 | connectedUsersMap.set(username, socketId); 41 | } 42 | } 43 | 44 | private removeClientFromMap(socketId: string): void { 45 | if (Array.from(connectedUsersMap.values()).includes(socketId)) { 46 | const disconnectedUser: [string, string] = [...connectedUsersMap].find((user: [string, string]) => { 47 | return user[1] === socketId; 48 | }) as [string, string]; 49 | connectedUsersMap.delete(disconnectedUser[0]); 50 | this.removeUser(disconnectedUser[0]); 51 | this.io.emit('user online', users); 52 | } 53 | } 54 | 55 | private addUser(username: string): void { 56 | users.push(username); 57 | users = [...new Set(users)]; 58 | } 59 | 60 | private removeUser(username: string): void { 61 | users = users.filter((name: string) => name != username); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/features/reactions/controllers/get-reactions.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import HTTP_STATUS from 'http-status-codes'; 3 | import { IReactionDocument } from '@reaction/interfaces/reaction.interface'; 4 | import { ReactionCache } from '@service/redis/reaction.cache'; 5 | import { reactionService } from '@service/db/reaction.service'; 6 | import mongoose from 'mongoose'; 7 | 8 | const reactionCache: ReactionCache = new ReactionCache(); 9 | 10 | export class Get { 11 | public async reactions(req: Request, res: Response): Promise { 12 | const { postId } = req.params; 13 | const cachedReactions: [IReactionDocument[], number] = await reactionCache.getReactionsFromCache(postId); 14 | const reactions: [IReactionDocument[], number] = cachedReactions[0].length 15 | ? cachedReactions 16 | : await reactionService.getPostReactions({ postId: new mongoose.Types.ObjectId(postId) }, { createdAt: -1 }); 17 | res.status(HTTP_STATUS.OK).json({ message: 'Post reactions', reactions: reactions[0], count: reactions[1] }); 18 | } 19 | 20 | public async singleReactionByUsername(req: Request, res: Response): Promise { 21 | const { postId, username } = req.params; 22 | const cachedReaction: [IReactionDocument, number] | [] = await reactionCache.getSingleReactionByUsernameFromCache(postId, username); 23 | const reactions: [IReactionDocument, number] | [] = cachedReaction.length 24 | ? cachedReaction 25 | : await reactionService.getSinglePostReactionByUsername(postId, username); 26 | res.status(HTTP_STATUS.OK).json({ 27 | message: 'Single post reaction by username', 28 | reactions: reactions.length ? reactions[0] : {}, 29 | count: reactions.length ? reactions[1] : 0 30 | }); 31 | } 32 | 33 | public async reactionsByUsername(req: Request, res: Response): Promise { 34 | const { username } = req.params; 35 | const reactions: IReactionDocument[] = await reactionService.getReactionsByUsername(username); 36 | res.status(HTTP_STATUS.OK).json({ message: 'All user reactions by username', reactions }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/features/auth/controllers/signin.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { config } from '@root/config'; 3 | import JWT from 'jsonwebtoken'; 4 | import { joiValidation } from '@global/decorators/joi-validation.decorators'; 5 | import HTTP_STATUS from 'http-status-codes'; 6 | import { authService } from '@service/db/auth.service'; 7 | import { loginSchema } from '@auth/schemes/signin'; 8 | import { IAuthDocument } from '@auth/interfaces/auth.interface'; 9 | import { BadRequestError } from '@global/helpers/error-handler'; 10 | import { userService } from '@service/db/user.service'; 11 | import { IUserDocument } from '@user/interfaces/user.interface'; 12 | 13 | export class SignIn { 14 | @joiValidation(loginSchema) 15 | public async read(req: Request, res: Response): Promise { 16 | const { username, password } = req.body; 17 | const existingUser: IAuthDocument = await authService.getAuthUserByUsername(username); 18 | if (!existingUser) { 19 | throw new BadRequestError('Invalid credentials'); 20 | } 21 | 22 | const passwordsMatch: boolean = await existingUser.comparePassword(password); 23 | if (!passwordsMatch) { 24 | throw new BadRequestError('Invalid credentials'); 25 | } 26 | const user: IUserDocument = await userService.getUserByAuthId(`${existingUser._id}`); 27 | const userJwt: string = JWT.sign( 28 | { 29 | userId: user._id, 30 | uId: existingUser.uId, 31 | email: existingUser.email, 32 | username: existingUser.username, 33 | avatarColor: existingUser.avatarColor 34 | }, 35 | config.JWT_TOKEN! 36 | ); 37 | req.session = { jwt: userJwt }; 38 | const userDocument: IUserDocument = { 39 | ...user, 40 | authId: existingUser!._id, 41 | username: existingUser!.username, 42 | email: existingUser!.email, 43 | avatarColor: existingUser!.avatarColor, 44 | uId: existingUser!.uId, 45 | createdAt: existingUser!.createdAt 46 | } as IUserDocument; 47 | res.status(HTTP_STATUS.OK).json({ message: 'User login successfully', user: userDocument, token: userJwt }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /endpoints/chat.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:5000 2 | @urlPath = api/v1 3 | 4 | ### 5 | GET {{baseUrl}}/{{urlPath}}/chat/message/conversation-list 6 | Content-Type: application/json 7 | Accept: application/json 8 | withCredentials: true 9 | 10 | ### 11 | GET {{baseUrl}}/{{urlPath}}/chat/message/user/ 12 | Content-Type: application/json 13 | Accept: application/json 14 | withCredentials: true 15 | 16 | ### 17 | POST {{baseUrl}}/{{urlPath}}/chat/message 18 | Content-Type: application/json 19 | Accept: application/json 20 | withCredentials: true 21 | 22 | { 23 | "conversationId": "", 24 | "receiverId": "", 25 | "receiverUsername": "", 26 | "receiverAvatarColor": "", 27 | "receiverProfilePicture": "", 28 | "body": "", 29 | "gifUrl": "", 30 | "isRead": false, 31 | "selectedImage": "" 32 | } 33 | 34 | ### 35 | POST {{baseUrl}}/{{urlPath}}/chat/message/add-chat-users 36 | Content-Type: application/json 37 | Accept: application/json 38 | withCredentials: true 39 | 40 | { 41 | "userOne": "", 42 | "userTwo": "" 43 | } 44 | 45 | ### 46 | POST {{baseUrl}}/{{urlPath}}/chat/message/remove-chat-users 47 | Content-Type: application/json 48 | Accept: application/json 49 | withCredentials: true 50 | 51 | { 52 | "userOne": "", 53 | "userTwo": "" 54 | } 55 | 56 | ### 57 | PUT {{baseUrl}}/{{urlPath}}/chat/message/mark-as-read 58 | Content-Type: application/json 59 | Accept: application/json 60 | withCredentials: true 61 | 62 | { 63 | "senderId": "", 64 | "receiverId": "" 65 | } 66 | 67 | ### 68 | PUT {{baseUrl}}/{{urlPath}}/chat/message/reaction 69 | Content-Type: application/json 70 | Accept: application/json 71 | withCredentials: true 72 | 73 | # For this endpoint, the type is either add or remove 74 | { 75 | "conversationId": "", 76 | "messageId": "", 77 | "reaction": "", 78 | "type": "" 79 | } 80 | 81 | ### 82 | DELETE {{baseUrl}}/{{urlPath}}/chat/message/mark-as-deleted//// 83 | # For this endpoint, the type is either deleteForMe or deleteForEveryone 84 | Content-Type: application/json 85 | Accept: application/json 86 | withCredentials: true 87 | -------------------------------------------------------------------------------- /src/features/auth/controllers/test/current-user.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { CurrentUser } from '@auth/controllers/current-user'; 3 | import { authMockRequest, authMockResponse, authUserPayload } from '@root/mocks/auth.mock'; 4 | import { existingUser } from '@root/mocks/user.mock'; 5 | import { UserCache } from '@service/redis/user.cache'; 6 | import { IUserDocument } from '@user/interfaces/user.interface'; 7 | 8 | jest.mock('@service/queues/base.queue'); 9 | jest.mock('@service/redis/user.cache'); 10 | jest.mock('@service/db/user.service'); 11 | 12 | const USERNAME = 'Manny'; 13 | const PASSWORD = 'manny1'; 14 | 15 | describe('CurrentUser', () => { 16 | beforeEach(() => { 17 | jest.restoreAllMocks(); 18 | }); 19 | 20 | afterEach(() => { 21 | jest.clearAllMocks(); 22 | }); 23 | 24 | describe('token', () => { 25 | it('should set session token to null and send correct json response', async () => { 26 | const req: Request = authMockRequest({}, { username: USERNAME, password: PASSWORD }, authUserPayload) as Request; 27 | const res: Response = authMockResponse(); 28 | jest.spyOn(UserCache.prototype, 'getUserFromCache').mockResolvedValue({} as IUserDocument); 29 | 30 | await CurrentUser.prototype.read(req, res); 31 | expect(res.status).toHaveBeenCalledWith(200); 32 | expect(res.json).toHaveBeenCalledWith({ 33 | token: null, 34 | isUser: false, 35 | user: null 36 | }); 37 | }); 38 | 39 | it('should set session token and send correct json response', async () => { 40 | const req: Request = authMockRequest({ jwt: '12djdj34' }, { username: USERNAME, password: PASSWORD }, authUserPayload) as Request; 41 | const res: Response = authMockResponse(); 42 | jest.spyOn(UserCache.prototype, 'getUserFromCache').mockResolvedValue(existingUser); 43 | 44 | await CurrentUser.prototype.read(req, res); 45 | expect(res.status).toHaveBeenCalledWith(200); 46 | expect(res.json).toHaveBeenCalledWith({ 47 | token: req.session?.jwt, 48 | isUser: true, 49 | user: existingUser 50 | }); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /endpoints/user.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:5000 2 | @urlPath = api/v1 3 | 4 | ### 5 | GET {{baseUrl}}/{{urlPath}}/user/all/ 6 | # GET {{baseUrl}}/{{urlPath}}/user/all/1 7 | Content-Type: application/json 8 | Accept: application/json 9 | withCredentials: true 10 | 11 | ### 12 | GET {{baseUrl}}/{{urlPath}}/user/profile 13 | Content-Type: application/json 14 | Accept: application/json 15 | withCredentials: true 16 | 17 | ### 18 | GET {{baseUrl}}/{{urlPath}}/user/profile/ 19 | Content-Type: application/json 20 | Accept: application/json 21 | withCredentials: true 22 | 23 | ### 24 | GET {{baseUrl}}/{{urlPath}}/user/profile/posts/// 25 | Content-Type: application/json 26 | Accept: application/json 27 | withCredentials: true 28 | 29 | ### 30 | GET {{baseUrl}}/{{urlPath}}/user/profile/user/suggestions 31 | Content-Type: application/json 32 | Accept: application/json 33 | withCredentials: true 34 | 35 | ### 36 | GET {{baseUrl}}/{{urlPath}}/user/profile/search/ 37 | Content-Type: application/json 38 | Accept: application/json 39 | withCredentials: true 40 | 41 | ### 42 | PUT {{baseUrl}}/{{urlPath}}/user/profile/change-password 43 | Content-Type: application/json 44 | Accept: application/json 45 | withCredentials: true 46 | 47 | { 48 | "currentPassword": "", 49 | "newPassword": "", 50 | "confirmPassword": "" 51 | } 52 | 53 | ### 54 | PUT {{baseUrl}}/{{urlPath}}/user/profile/basic-info 55 | content-type: application/json 56 | Accept: 'application/json' 57 | withCredentials: true 58 | 59 | { 60 | "quote": "", 61 | "work": "", 62 | "school": "", 63 | "location": "" 64 | } 65 | 66 | ### 67 | PUT {{baseUrl}}/{{urlPath}}/user/profile/social-links 68 | content-type: application/json 69 | Accept: 'application/json' 70 | withCredentials: true 71 | 72 | { 73 | "instagram": "", 74 | "twitter": "", 75 | "facebook": "", 76 | "youtube": "" 77 | } 78 | 79 | ### 80 | PUT {{baseUrl}}/{{urlPath}}/user/profile/settings 81 | content-type: application/json 82 | Accept: 'application/json' 83 | withCredentials: true 84 | 85 | { 86 | "messages": false, 87 | "reactions": true, 88 | "comments": false, 89 | "follows": true 90 | } 91 | -------------------------------------------------------------------------------- /src/features/comments/controllers/get-comments.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import HTTP_STATUS from 'http-status-codes'; 3 | import { ICommentDocument, ICommentNameList } from '@comment/interfaces/comment.interface'; 4 | import { CommentCache } from '@service/redis/comment.cache'; 5 | import { commentService } from '@service/db/comment.service'; 6 | import mongoose from 'mongoose'; 7 | 8 | const commentCache: CommentCache = new CommentCache(); 9 | 10 | export class Get { 11 | public async comments(req: Request, res: Response): Promise { 12 | const { postId } = req.params; 13 | const cachedComments: ICommentDocument[] = await commentCache.getCommentsFromCache(postId); 14 | const comments: ICommentDocument[] = cachedComments.length 15 | ? cachedComments 16 | : await commentService.getPostComments({ postId: new mongoose.Types.ObjectId(postId) }, { createdAt: -1 }); 17 | 18 | res.status(HTTP_STATUS.OK).json({ message: 'Post comments', comments }); 19 | } 20 | 21 | public async commentsNamesFromCache(req: Request, res: Response): Promise { 22 | const { postId } = req.params; 23 | const cachedCommentsNames: ICommentNameList[] = await commentCache.getCommentsNamesFromCache(postId); 24 | const commentsNames: ICommentNameList[] = cachedCommentsNames.length 25 | ? cachedCommentsNames 26 | : await commentService.getPostCommentNames({ postId: new mongoose.Types.ObjectId(postId) }, { createdAt: -1 }); 27 | 28 | res.status(HTTP_STATUS.OK).json({ message: 'Post comments names', comments: commentsNames.length ? commentsNames[0] : [] }); 29 | } 30 | 31 | public async singleComment(req: Request, res: Response): Promise { 32 | const { postId, commentId } = req.params; 33 | const cachedComments: ICommentDocument[] = await commentCache.getSingleCommentFromCache(postId, commentId); 34 | const comments: ICommentDocument[] = cachedComments.length 35 | ? cachedComments 36 | : await commentService.getPostComments({ _id: new mongoose.Types.ObjectId(commentId) }, { createdAt: -1 }); 37 | 38 | res.status(HTTP_STATUS.OK).json({ message: 'Single comment', comments: comments.length ? comments[0] : [] }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/features/reactions/controllers/test/add-reactions.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { authUserPayload } from '@root/mocks/auth.mock'; 3 | import { reactionMockRequest, reactionMockResponse } from '@root/mocks/reactions.mock'; 4 | import { ReactionCache } from '@service/redis/reaction.cache'; 5 | import { reactionQueue } from '@service/queues/reaction.queue'; 6 | import { Add } from '@reaction/controllers/add-reactions'; 7 | 8 | jest.useFakeTimers(); 9 | jest.mock('@service/queues/base.queue'); 10 | jest.mock('@service/redis/reaction.cache'); 11 | 12 | describe('AddReaction', () => { 13 | beforeEach(() => { 14 | jest.restoreAllMocks(); 15 | }); 16 | 17 | afterEach(() => { 18 | jest.clearAllMocks(); 19 | jest.clearAllTimers(); 20 | }); 21 | 22 | it('should send correct json response', async () => { 23 | const req: Request = reactionMockRequest( 24 | {}, 25 | { 26 | postId: '6027f77087c9d9ccb1555268', 27 | previousReaction: 'love', 28 | profilePicture: 'http://place-hold.it/500x500', 29 | userTo: '60263f14648fed5246e322d9', 30 | type: 'like', 31 | postReactions: { 32 | like: 1, 33 | love: 0, 34 | happy: 0, 35 | wow: 0, 36 | sad: 0, 37 | angry: 0 38 | } 39 | }, 40 | authUserPayload 41 | ) as Request; 42 | const res: Response = reactionMockResponse(); 43 | const spy = jest.spyOn(ReactionCache.prototype, 'savePostReactionToCache'); 44 | const reactionSpy = jest.spyOn(reactionQueue, 'addReactionJob'); 45 | 46 | await Add.prototype.reaction(req, res); 47 | expect(ReactionCache.prototype.savePostReactionToCache).toHaveBeenCalledWith( 48 | spy.mock.calls[0][0], 49 | spy.mock.calls[0][1], 50 | spy.mock.calls[0][2], 51 | spy.mock.calls[0][3], 52 | spy.mock.calls[0][4] 53 | ); 54 | expect(reactionQueue.addReactionJob).toHaveBeenCalledWith(reactionSpy.mock.calls[0][0], reactionSpy.mock.calls[0][1]); 55 | expect(res.status).toHaveBeenCalledWith(200); 56 | expect(res.json).toHaveBeenCalledWith({ 57 | message: 'Reaction added successfully' 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/mocks/reactions.mock.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express'; 2 | import { AuthPayload } from '@auth/interfaces/auth.interface'; 3 | import { IReactionDocument, IReactions } from '@reaction/interfaces/reaction.interface'; 4 | import { IJWT } from './auth.mock'; 5 | import { ICommentDocument, ICommentNameList } from '@comment/interfaces/comment.interface'; 6 | 7 | export const reactionMockRequest = (sessionData: IJWT, body: IBody, currentUser?: AuthPayload | null, params?: IParams) => ({ 8 | session: sessionData, 9 | body, 10 | params, 11 | currentUser 12 | }); 13 | 14 | export const reactionMockResponse = (): Response => { 15 | const res: Response = {} as Response; 16 | res.status = jest.fn().mockReturnValue(res); 17 | res.json = jest.fn().mockReturnValue(res); 18 | return res; 19 | }; 20 | 21 | export interface IBody { 22 | postId?: string; 23 | comment?: string; 24 | profilePicture?: string; 25 | userTo?: string; 26 | type?: string; 27 | previousReaction?: string; 28 | postReactions?: IReactions; 29 | } 30 | 31 | export interface IParams { 32 | postId?: string; 33 | page?: string; 34 | commentId?: string; 35 | reactionId?: string; 36 | previousReaction?: string; 37 | username?: string; 38 | postReactions?: string; 39 | } 40 | 41 | export const reactionData: IReactionDocument = { 42 | _id: '6064861bc25eaa5a5d2f9bf4', 43 | username: 'Danny', 44 | postId: '6027f77087c9d9ccb1555268', 45 | profilePicture: 'https://res.cloudinary.com/ratingapp/image/upload/6064793b091bf02b6a71067a', 46 | comment: 'This is a comment', 47 | createdAt: new Date(), 48 | userTo: '60263f14648fed5246e322d9', 49 | type: 'love' 50 | } as IReactionDocument; 51 | 52 | export const commentsData: ICommentDocument = { 53 | _id: '6064861bc25eaa5a5d2f9bf4', 54 | username: 'Danny', 55 | avatarColor: '#9c27b0', 56 | postId: '6027f77087c9d9ccb1555268', 57 | profilePicture: 'https://res.cloudinary.com/ratingapp/image/upload/6064793b091bf02b6a71067a', 58 | comment: 'This is a comment', 59 | createdAt: new Date(), 60 | userTo: '60263f14648fed5246e322d9' 61 | } as unknown as ICommentDocument; 62 | 63 | export const commentNames: ICommentNameList = { 64 | count: 1, 65 | names: ['Danny'] 66 | }; 67 | -------------------------------------------------------------------------------- /src/features/user/interfaces/user.interface.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document } from 'mongoose'; 2 | import { ObjectId } from 'mongodb'; 3 | 4 | export interface IUserDocument extends Document { 5 | _id: string | ObjectId; 6 | authId: string | ObjectId; 7 | username?: string; 8 | email?: string; 9 | password?: string; 10 | avatarColor?: string; 11 | uId?: string; 12 | postsCount: number; 13 | work: string; 14 | school: string; 15 | quote: string; 16 | location: string; 17 | blocked: mongoose.Types.ObjectId[]; 18 | blockedBy: mongoose.Types.ObjectId[]; 19 | followersCount: number; 20 | followingCount: number; 21 | notifications: INotificationSettings; 22 | social: ISocialLinks; 23 | bgImageVersion: string; 24 | bgImageId: string; 25 | profilePicture: string; 26 | createdAt?: Date; 27 | } 28 | 29 | export interface IResetPasswordParams { 30 | username: string; 31 | email: string; 32 | ipaddress: string; 33 | date: string; 34 | } 35 | 36 | export interface INotificationSettings { 37 | messages: boolean; 38 | reactions: boolean; 39 | comments: boolean; 40 | follows: boolean; 41 | } 42 | 43 | export interface IBasicInfo { 44 | quote: string; 45 | work: string; 46 | school: string; 47 | location: string; 48 | } 49 | 50 | export interface ISocialLinks { 51 | facebook: string; 52 | instagram: string; 53 | twitter: string; 54 | youtube: string; 55 | } 56 | 57 | export interface ISearchUser { 58 | _id: string; 59 | profilePicture: string; 60 | username: string; 61 | email: string; 62 | avatarColor: string; 63 | } 64 | 65 | export interface ISocketData { 66 | blockedUser: string; 67 | blockedBy: string; 68 | } 69 | 70 | export interface ILogin { 71 | userId: string; 72 | } 73 | 74 | export interface IUserJobInfo { 75 | key?: string; 76 | value?: string | ISocialLinks; 77 | } 78 | 79 | export interface IUserJob { 80 | keyOne?: string; 81 | keyTwo?: string; 82 | key?: string; 83 | value?: string | INotificationSettings | IUserDocument; 84 | } 85 | 86 | export interface IEmailJob { 87 | receiverEmail: string; 88 | template: string; 89 | subject: string; 90 | } 91 | 92 | export interface IAllUsers { 93 | users: IUserDocument[]; 94 | totalUsers: number; 95 | } 96 | -------------------------------------------------------------------------------- /endpoints/posts.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:5000 2 | @urlPath = api/v1 3 | 4 | ### 5 | GET {{baseUrl}}/{{urlPath}}/post/all/ 6 | # GET {{baseUrl}}/{{urlPath}}/post/all/1 7 | Content-Type: application/json 8 | Accept: application/json 9 | withCredentials: true 10 | 11 | ### 12 | GET {{baseUrl}}/{{urlPath}}/post/images/ 13 | # GET {{baseUrl}}/{{urlPath}}/post/images/1 14 | Content-Type: application/json 15 | Accept: application/json 16 | withCredentials: true 17 | 18 | ### 19 | POST {{baseUrl}}/{{urlPath}}/post 20 | Content-Type: application/json 21 | Accept: application/json 22 | withCredentials: true 23 | 24 | # Privacy - Public | Private | Followers 25 | # Feelings - like | love | wow | happy | sad | angry 26 | 27 | { 28 | "post": "", 29 | "bgColor": "", 30 | "privacy": "Public", 31 | "gifUrl": "", 32 | "profilePicture": "", 33 | "feelings": "" 34 | } 35 | 36 | ### 37 | POST {{baseUrl}}/{{urlPath}}/post/image/post 38 | Content-Type: application/json 39 | Accept: application/json 40 | withCredentials: true 41 | 42 | { 43 | "post": "", 44 | "bgColor": "", 45 | "privacy": "", 46 | "gifUrl": "", 47 | "profilePicture": "", 48 | "feelings": "", 49 | "image": "" 50 | } 51 | 52 | ### 53 | PUT {{baseUrl}}/{{urlPath}}/post/ 54 | Content-Type: application/json 55 | Accept: application/json 56 | withCredentials: true, 57 | 58 | { 59 | "post": "", 60 | "bgColor": "", 61 | "privacy": "", 62 | "gifUrl": "", 63 | "profilePicture": "", 64 | "feelings": "" 65 | } 66 | 67 | ### 68 | PUT {{baseUrl}}/{{urlPath}}/post/image/ 69 | Content-Type: application/json 70 | Accept: application/json 71 | withCredentials: true, 72 | 73 | { 74 | "post": "", 75 | "bgColor": "", 76 | "privacy": "", 77 | "gifUrl": "", 78 | "profilePicture": "", 79 | "feelings": "", 80 | "imgId": "", 81 | "imgVersion": "", 82 | "image": "" 83 | } 84 | 85 | ### 86 | DELETE {{baseUrl}}/{{urlPath}}/post/ 87 | Content-Type: application/json 88 | Accept: application/json 89 | withCredentials: true 90 | -------------------------------------------------------------------------------- /src/shared/services/db/post.service.ts: -------------------------------------------------------------------------------- 1 | import { IPostDocument, IGetPostsQuery, IQueryComplete, IQueryDeleted } from '@post/interfaces/post.interface'; 2 | import { PostModel } from '@post/models/post.schema'; 3 | import { IUserDocument } from '@user/interfaces/user.interface'; 4 | import { UserModel } from '@user/models/user.schema'; 5 | import { Query, UpdateQuery } from 'mongoose'; 6 | 7 | class PostService { 8 | public async addPostToDB(userId: string, createdPost: IPostDocument): Promise { 9 | const post: Promise = PostModel.create(createdPost); 10 | const user: UpdateQuery = UserModel.updateOne({ _id: userId }, { $inc: { postsCount: 1 } }); 11 | await Promise.all([post, user]); 12 | } 13 | 14 | public async getPosts(query: IGetPostsQuery, skip = 0, limit = 0, sort: Record): Promise { 15 | let postQuery = {}; 16 | if (query?.imgId && query?.gifUrl) { 17 | postQuery = { $or: [{ imgId: { $ne: '' } }, { gifUrl: { $ne: '' } }] }; 18 | } else if (query?.videoId) { 19 | postQuery = { $or: [{ videoId: { $ne: '' } }] }; 20 | } else { 21 | postQuery = query; 22 | } 23 | const posts: IPostDocument[] = await PostModel.aggregate([{ $match: postQuery }, { $sort: sort }, { $skip: skip }, { $limit: limit }]); 24 | return posts; 25 | } 26 | 27 | public async postsCount(): Promise { 28 | const count: number = await PostModel.find({}).countDocuments(); 29 | return count; 30 | } 31 | 32 | public async deletePost(postId: string, userId: string): Promise { 33 | const deletePost: Query = PostModel.deleteOne({ _id: postId }); 34 | // delete reactions here 35 | const decrementPostCount: UpdateQuery = UserModel.updateOne({ _id: userId }, { $inc: { postsCount: -1 } }); 36 | await Promise.all([deletePost, decrementPostCount]); 37 | } 38 | 39 | public async editPost(postId: string, updatedPost: IPostDocument): Promise { 40 | const updatePost: UpdateQuery = PostModel.updateOne({ _id: postId }, { $set: updatedPost }); 41 | await Promise.all([updatePost]); 42 | } 43 | } 44 | 45 | export const postService: PostService = new PostService(); 46 | -------------------------------------------------------------------------------- /src/features/comments/controllers/test/add-comment.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { authUserPayload } from '@root/mocks/auth.mock'; 3 | import { reactionMockRequest, reactionMockResponse } from '@root/mocks/reactions.mock'; 4 | import { CommentCache } from '@service/redis/comment.cache'; 5 | import { commentQueue } from '@service/queues/comment.queue'; 6 | import { Add } from '@comment/controllers/add-comment'; 7 | import { existingUser } from '@root/mocks/user.mock'; 8 | 9 | jest.useFakeTimers(); 10 | jest.mock('@service/queues/base.queue'); 11 | jest.mock('@service/redis/comment.cache'); 12 | 13 | describe('Add', () => { 14 | beforeEach(() => { 15 | jest.restoreAllMocks(); 16 | }); 17 | 18 | afterEach(() => { 19 | jest.clearAllMocks(); 20 | jest.clearAllTimers(); 21 | }); 22 | 23 | it('should call savePostCommentToCache and addCommentJob methods', async () => { 24 | const req: Request = reactionMockRequest( 25 | {}, 26 | { 27 | postId: '6027f77087c9d9ccb1555268', 28 | comment: 'This is a comment', 29 | profilePicture: 'https://place-hold.it/500x500', 30 | userTo: `${existingUser._id}` 31 | }, 32 | authUserPayload 33 | ) as Request; 34 | const res: Response = reactionMockResponse(); 35 | jest.spyOn(CommentCache.prototype, 'savePostCommentToCache'); 36 | jest.spyOn(commentQueue, 'addCommentJob'); 37 | 38 | await Add.prototype.comment(req, res); 39 | expect(CommentCache.prototype.savePostCommentToCache).toHaveBeenCalled(); 40 | expect(commentQueue.addCommentJob).toHaveBeenCalled(); 41 | }); 42 | 43 | it('should send correct json response', async () => { 44 | const req: Request = reactionMockRequest( 45 | {}, 46 | { 47 | postId: '6027f77087c9d9ccb1555268', 48 | comment: 'This is a comment', 49 | profilePicture: 'https://place-hold.it/500x500', 50 | userTo: `${existingUser._id}` 51 | }, 52 | authUserPayload 53 | ) as Request; 54 | const res: Response = reactionMockResponse(); 55 | 56 | await Add.prototype.comment(req, res); 57 | expect(res.status).toHaveBeenCalledWith(200); 58 | expect(res.json).toHaveBeenCalledWith({ 59 | message: 'Comment created successfully' 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/features/user/controllers/change-password.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import HTTP_STATUS from 'http-status-codes'; 3 | import moment from 'moment'; 4 | import publicIP from 'ip'; 5 | import { userService } from '@service/db/user.service'; 6 | import { IResetPasswordParams } from '@user/interfaces/user.interface'; 7 | import { joiValidation } from '@global/decorators/joi-validation.decorators'; 8 | import { changePasswordSchema } from '@user/schemes/info'; 9 | import { BadRequestError } from '@global/helpers/error-handler'; 10 | import { authService } from '@service/db/auth.service'; 11 | import { IAuthDocument } from '@auth/interfaces/auth.interface'; 12 | import { resetPasswordTemplate } from '@service/emails/templates/reset-password/reset-password-template'; 13 | import { emailQueue } from '@service/queues/email.queue'; 14 | 15 | export class Update { 16 | @joiValidation(changePasswordSchema) 17 | public async password(req: Request, res: Response): Promise { 18 | const { currentPassword, newPassword, confirmPassword } = req.body; 19 | if (newPassword !== confirmPassword) { 20 | throw new BadRequestError('Passwords do not match.'); 21 | } 22 | const existingUser: IAuthDocument = await authService.getAuthUserByUsername(req.currentUser!.username); 23 | const passwordsMatch: boolean = await existingUser.comparePassword(currentPassword); 24 | if (!passwordsMatch) { 25 | throw new BadRequestError('Invalid credentials'); 26 | } 27 | const hashedPassword: string = await existingUser.hashPassword(newPassword); 28 | userService.updatePassword(`${req.currentUser!.username}`, hashedPassword); 29 | 30 | const templateParams: IResetPasswordParams = { 31 | username: existingUser.username!, 32 | email: existingUser.email!, 33 | ipaddress: publicIP.address(), 34 | date: moment().format('DD//MM//YYYY HH:mm') 35 | }; 36 | const template: string = resetPasswordTemplate.passwordResetConfirmationTemplate(templateParams); 37 | emailQueue.addEmailJob('changePassword', { template, receiverEmail: existingUser.email!, subject: 'Password update confirmation' }); 38 | res.status(HTTP_STATUS.OK).json({ 39 | message: 'Password updated successfully. You will be redirected shortly to the login page.' 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/seeds.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import dotenv from 'dotenv'; 3 | import { faker } from '@faker-js/faker'; 4 | import { floor, random } from 'lodash'; 5 | import axios from 'axios'; 6 | import { createCanvas } from 'canvas'; 7 | 8 | dotenv.config({}); 9 | 10 | function avatarColor(): string { 11 | const colors: string[] = [ 12 | '#f44336', 13 | '#e91e63', 14 | '#2196f3', 15 | '#9c27b0', 16 | '#3f51b5', 17 | '#00bcd4', 18 | '#4caf50', 19 | '#ff9800', 20 | '#8bc34a', 21 | '#009688', 22 | '#03a9f4', 23 | '#cddc39', 24 | '#2962ff', 25 | '#448aff', 26 | '#84ffff', 27 | '#00e676', 28 | '#43a047', 29 | '#d32f2f', 30 | '#ff1744', 31 | '#ad1457', 32 | '#6a1b9a', 33 | '#1a237e', 34 | '#1de9b6', 35 | '#d84315' 36 | ]; 37 | return colors[floor(random(0.9) * colors.length)]; 38 | } 39 | 40 | function generateAvatar(text: string, backgroundColor: string, foregroundColor = 'white') { 41 | const canvas = createCanvas(200, 200); 42 | const context = canvas.getContext('2d'); 43 | 44 | context.fillStyle = backgroundColor; 45 | context.fillRect(0, 0, canvas.width, canvas.height); 46 | 47 | context.font = 'normal 80px sans-serif'; 48 | context.fillStyle = foregroundColor; 49 | context.textAlign = 'center'; 50 | context.textBaseline = 'middle'; 51 | context.fillText(text, canvas.width / 2, canvas.height / 2); 52 | 53 | return canvas.toDataURL('image/png'); 54 | } 55 | 56 | async function seedUserData(count: number): Promise { 57 | let i = 0; 58 | try { 59 | for (i = 0; i < count; i++) { 60 | const username: string = faker.unique(faker.word.adjective, [8]); 61 | const color = avatarColor(); 62 | const avatar = generateAvatar(username.charAt(0).toUpperCase(), color); 63 | 64 | const body = { 65 | username, 66 | email: faker.internet.email(), 67 | password: 'qwerty', 68 | avatarColor: color, 69 | avatarImage: avatar 70 | }; 71 | console.log(`***ADDING USER TO DATABASE*** - ${i + 1} of ${count} - ${username}`); 72 | await axios.post(`${process.env.API_URL}/signup`, body); 73 | } 74 | } catch (error: any) { 75 | console.log(error?.response?.data); 76 | } 77 | } 78 | 79 | seedUserData(10); 80 | -------------------------------------------------------------------------------- /deployment/22-asg_policy.tf: -------------------------------------------------------------------------------- 1 | resource "aws_autoscaling_policy" "asg_scale_out_policy" { 2 | name = "ASG-SCALE-OUT-POLICY" 3 | autoscaling_group_name = aws_autoscaling_group.ec2_autoscaling_group.name 4 | adjustment_type = "ChangeInCapacity" 5 | policy_type = "SimpleScaling" 6 | scaling_adjustment = 1 7 | cooldown = 150 8 | depends_on = [ 9 | aws_autoscaling_group.ec2_autoscaling_group 10 | ] 11 | } 12 | 13 | resource "aws_cloudwatch_metric_alarm" "ec2_scale_out_alarm" { 14 | alarm_name = "EC2-SCALE-OUT-ALARM" 15 | alarm_description = "This metric monitors EC2 CPU utilization" 16 | comparison_operator = "GreaterThanOrEqualToThreshold" 17 | evaluation_periods = "1" 18 | metric_name = "CPUUtilization" 19 | namespace = "AWS/EC2" 20 | period = "120" 21 | statistic = "Average" 22 | threshold = 50 23 | dimensions = { 24 | AutoScalingGroupName = aws_autoscaling_group.ec2_autoscaling_group.name 25 | } 26 | alarm_actions = [aws_autoscaling_policy.asg_scale_out_policy.arn] 27 | depends_on = [ 28 | aws_autoscaling_group.ec2_autoscaling_group 29 | ] 30 | } 31 | 32 | resource "aws_autoscaling_policy" "asg_scale_in_policy" { 33 | name = "ASG-SCALE-IN-POLICY" 34 | autoscaling_group_name = aws_autoscaling_group.ec2_autoscaling_group.name 35 | adjustment_type = "ChangeInCapacity" 36 | policy_type = "SimpleScaling" 37 | scaling_adjustment = -1 38 | cooldown = 150 39 | depends_on = [ 40 | aws_autoscaling_group.ec2_autoscaling_group 41 | ] 42 | } 43 | 44 | resource "aws_cloudwatch_metric_alarm" "ec2_scale_in_alarm" { 45 | alarm_name = "EC2-SCALE-IN-ALARM" 46 | alarm_description = "This metric monitors EC2 CPU utilization" 47 | comparison_operator = "LessThanOrEqualToThreshold" 48 | evaluation_periods = "1" 49 | metric_name = "CPUUtilization" 50 | namespace = "AWS/EC2" 51 | period = "120" 52 | statistic = "Average" 53 | threshold = 10 54 | dimensions = { 55 | AutoScalingGroupName = aws_autoscaling_group.ec2_autoscaling_group.name 56 | } 57 | alarm_actions = [aws_autoscaling_policy.asg_scale_in_policy.arn] 58 | depends_on = [ 59 | aws_autoscaling_group.ec2_autoscaling_group 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /src/shared/services/emails/mail.transport.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from 'nodemailer'; 2 | import Mail from 'nodemailer/lib/mailer'; 3 | import Logger from 'bunyan'; 4 | import sendGridMail from '@sendgrid/mail'; 5 | import { config } from '@root/config'; 6 | import { BadRequestError } from '@global/helpers/error-handler'; 7 | 8 | interface IMailOptions { 9 | from: string; 10 | to: string; 11 | subject: string; 12 | html: string; 13 | } 14 | 15 | const log: Logger = config.createLogger('mailOptions'); 16 | sendGridMail.setApiKey(config.SENDGRID_API_KEY!); 17 | 18 | class MailTransport { 19 | public async sendEmail(receiverEmail: string, subject: string, body: string): Promise { 20 | if (config.NODE_ENV === 'test' || config.NODE_ENV === 'development') { 21 | this.developmentEmailSender(receiverEmail, subject, body); 22 | } else { 23 | this.productionEmailSender(receiverEmail, subject, body); 24 | } 25 | } 26 | 27 | private async developmentEmailSender(receiverEmail: string, subject: string, body: string): Promise { 28 | const transporter: Mail = nodemailer.createTransport({ 29 | host: 'smtp.ethereal.email', 30 | port: 587, 31 | secure: false, 32 | auth: { 33 | user: config.SENDER_EMAIL!, 34 | pass: config.SENDER_EMAIL_PASSWORD! 35 | } 36 | }); 37 | 38 | const mailOptions: IMailOptions = { 39 | from: `Chatty App <${config.SENDER_EMAIL!}>`, 40 | to: receiverEmail, 41 | subject, 42 | html: body 43 | }; 44 | 45 | try { 46 | await transporter.sendMail(mailOptions); 47 | log.info('Development email sent successfully.'); 48 | } catch (error) { 49 | log.error('Error sending email', error); 50 | throw new BadRequestError('Error sending email'); 51 | } 52 | } 53 | 54 | private async productionEmailSender(receiverEmail: string, subject: string, body: string): Promise { 55 | const mailOptions: IMailOptions = { 56 | from: `Chatty App <${config.SENDER_EMAIL!}>`, 57 | to: receiverEmail, 58 | subject, 59 | html: body 60 | }; 61 | 62 | try { 63 | await sendGridMail.send(mailOptions); 64 | log.info('Production email sent successfully.'); 65 | } catch (error) { 66 | log.error('Error sending email', error); 67 | throw new BadRequestError('Error sending email'); 68 | } 69 | } 70 | } 71 | 72 | export const mailTransport: MailTransport = new MailTransport(); 73 | -------------------------------------------------------------------------------- /src/features/user/routes/healthRoutes.ts: -------------------------------------------------------------------------------- 1 | import express, { Router, Request, Response } from 'express'; 2 | import moment from 'moment'; 3 | import axios from 'axios'; 4 | import { performance } from 'perf_hooks'; 5 | import HTTP_STATUS from 'http-status-codes'; 6 | import { config } from '@root/config'; 7 | 8 | class HealthRoutes { 9 | private router: Router; 10 | 11 | constructor() { 12 | this.router = express.Router(); 13 | } 14 | 15 | public health(): Router { 16 | this.router.get('/health', (req: Request, res: Response) => { 17 | res.status(HTTP_STATUS.OK).send(`Health: Server instance is healthy with process id ${process.pid} on ${moment().format('LL')}`); 18 | }); 19 | 20 | return this.router; 21 | } 22 | 23 | public env(): Router { 24 | this.router.get('/env', (req: Request, res: Response) => { 25 | res.status(HTTP_STATUS.OK).send(`This is the ${config.NODE_ENV} environment.rfrgrg5t3e2e3e3r4g5g`); 26 | }); 27 | 28 | return this.router; 29 | } 30 | 31 | public instance(): Router { 32 | this.router.get('/instance', async (req: Request, res: Response) => { 33 | const response = await axios({ 34 | method: 'get', 35 | url: config.EC2_URL 36 | }); 37 | res 38 | .status(HTTP_STATUS.OK) 39 | .send(`Server is running on EC2 instance with id ${response.data} and process id ${process.pid} on ${moment().format('LL')}`); 40 | }); 41 | 42 | return this.router; 43 | } 44 | 45 | public fiboRoutes(): Router { 46 | this.router.get('/fibo/:num', async (req: Request, res: Response) => { 47 | const { num } = req.params; 48 | const start: number = performance.now(); 49 | const result: number = this.fibo(parseInt(num, 10)); 50 | const end: number = performance.now(); 51 | const response = await axios({ 52 | method: 'get', 53 | url: config.EC2_URL 54 | }); 55 | res 56 | .status(HTTP_STATUS.OK) 57 | .send( 58 | `Fibonacci series of ${num} is ${result} and it took ${end - start}ms and runs with process id ${process.pid} on ${ 59 | response.data 60 | } at ${moment().format('LL')}` 61 | ); 62 | }); 63 | 64 | return this.router; 65 | } 66 | 67 | private fibo(data: number): number { 68 | if (data < 2) { 69 | return 1; 70 | } else { 71 | return this.fibo(data - 2) + this.fibo(data - 1); 72 | } 73 | } 74 | } 75 | 76 | export const healthRoutes: HealthRoutes = new HealthRoutes(); 77 | -------------------------------------------------------------------------------- /src/features/chat/interfaces/chat.interface.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document } from 'mongoose'; 2 | import { AuthPayload } from '@auth/interfaces/auth.interface'; 3 | import { IReaction } from '@reaction/interfaces/reaction.interface'; 4 | 5 | export interface IMessageDocument extends Document { 6 | _id: mongoose.Types.ObjectId; 7 | conversationId: mongoose.Types.ObjectId; 8 | senderId: mongoose.Types.ObjectId; 9 | receiverId: mongoose.Types.ObjectId; 10 | senderUsername: string; 11 | senderAvatarColor: string; 12 | senderProfilePicture: string; 13 | receiverUsername: string; 14 | receiverAvatarColor: string; 15 | receiverProfilePicture: string; 16 | body: string; 17 | gifUrl: string; 18 | isRead: boolean; 19 | selectedImage: string; 20 | reaction: IReaction[]; 21 | createdAt: Date; 22 | deleteForMe: boolean; 23 | deleteForEveryone: boolean; 24 | } 25 | 26 | export interface IMessageData { 27 | _id: string | mongoose.Types.ObjectId; 28 | conversationId: mongoose.Types.ObjectId; 29 | receiverId: string; 30 | receiverUsername: string; 31 | receiverAvatarColor: string; 32 | receiverProfilePicture: string; 33 | senderUsername: string; 34 | senderId: string; 35 | senderAvatarColor: string; 36 | senderProfilePicture: string; 37 | body: string; 38 | isRead: boolean; 39 | gifUrl: string; 40 | selectedImage: string; 41 | reaction: IReaction[]; 42 | createdAt: Date | string; 43 | deleteForMe: boolean; 44 | deleteForEveryone: boolean; 45 | } 46 | 47 | export interface IMessageNotification { 48 | currentUser: AuthPayload; 49 | message: string; 50 | receiverName: string; 51 | receiverId: string; 52 | messageData: IMessageData; 53 | } 54 | 55 | export interface IChatUsers { 56 | userOne: string; 57 | userTwo: string; 58 | } 59 | 60 | export interface IChatList { 61 | receiverId: string; 62 | conversationId: string; 63 | } 64 | 65 | export interface ITyping { 66 | sender: string; 67 | receiver: string; 68 | } 69 | 70 | export interface IChatJobData { 71 | senderId?: mongoose.Types.ObjectId | string; 72 | receiverId?: mongoose.Types.ObjectId | string; 73 | messageId?: mongoose.Types.ObjectId | string; 74 | senderName?: string; 75 | reaction?: string; 76 | type?: string; 77 | } 78 | 79 | export interface ISenderReceiver { 80 | senderId: string; 81 | receiverId: string; 82 | senderName: string; 83 | receiverName: string; 84 | } 85 | 86 | export interface IGetMessageFromCache { 87 | index: number; 88 | message: string; 89 | receiver: IChatList; 90 | } 91 | --------------------------------------------------------------------------------